From bcd6834a0614ff1940e7863faaf19a60ea95680f Mon Sep 17 00:00:00 2001 From: Floke Date: Tue, 15 Apr 2025 12:57:27 +0000 Subject: [PATCH] v1.6.0: Refactoring - Code-Optimierung und Beseitigung von Redundanzen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Git-Änderungsbeschreibung: - Doppelte Funktionen (process_verification_only, _process_batch, count_linkedin_contacts) entfernt. - Doppelte DataProcessor-Klasse entfernt. - Ungenutzten Code (Timestamp-Handling, compare_umsatz_values, process_contacts) entfernt. - Google Sheet Updates optimiert durch konsequentere Nutzung von batch_update in Schleifen (_process_batch, process_website_batch, process_branch_batch, process_contact_research). - API-Key-Handling zentralisiert: Keys werden einmal in Config geladen und von dort bezogen. - Google Sheet Verbindung zentralisiert: Wird nur noch im GoogleSheetHandler aufgebaut. - Vorbereitung für robustere Spaltenzugriffe durch Einführung einer COLUMN_MAP (noch nicht durchgängig genutzt). - Modus-Steuerung in main() konsolidiert. - alignment_demo korrigiert (nur noch für Hauptblatt). Header für Contacts-Blatt in process_contact_research gesetzt. - Konstanten für Dateinamen eingeführt. --- brancheneinstufung.py | 4469 ++++++++++++++++++++--------------------- 1 file changed, 2139 insertions(+), 2330 deletions(-) diff --git a/brancheneinstufung.py b/brancheneinstufung.py index f18c8409..76213621 100644 --- a/brancheneinstufung.py +++ b/brancheneinstufung.py @@ -1,18 +1,22 @@ +```python #!/usr/bin/env python3 """ -Version: v1.5.8 +Version: v1.6.0 (Refactored) Datum: {aktuelles Datum} Git-Überschrift (max. 100 Zeichen): -v1.5.8: Externe Branchenzuordnung mittels Mapping verfeinert - +v1.6.0: Refactoring - Code-Optimierung und Beseitigung von Redundanzen Git-Änderungsbeschreibung: -- Mapping-Funktion load_branch_mapping() integriert, um aus der CSV "ziel_Branchenschema.csv" automatisch ein Mapping-Dictionary zu erstellen. -- Neue Funktion map_external_branch() implementiert, die den von ChatGPT gelieferten externen Branchenbegriff (nach Normalisierung) in das standardisierte Ziel-Branchenschema überführt. -- In evaluate_branche_chatgpt wird zuerst der ChatGPT-Vorschlag geparst, unerwünschte Präfixe entfernt und über map_external_branch() in den korrekten Standardwert transformiert. -- Optional wird der CRM-Präfix ergänzt, falls der Mapping-Wert kein hierarchisches Trennzeichen enthält. -- Damit wird der externe Input selbstbewusster übernommen, solange er durch das Mapping bestätigt wird. - +- Doppelte Funktionen (process_verification_only, _process_batch, count_linkedin_contacts) entfernt. +- Doppelte DataProcessor-Klasse entfernt. +- Ungenutzten Code (Timestamp-Handling, compare_umsatz_values, process_contacts) entfernt. +- Google Sheet Updates optimiert durch konsequentere Nutzung von batch_update in Schleifen (_process_batch, process_website_batch, process_branch_batch, process_contact_research). +- API-Key-Handling zentralisiert: Keys werden einmal in Config geladen und von dort bezogen. +- Google Sheet Verbindung zentralisiert: Wird nur noch im GoogleSheetHandler aufgebaut. +- Vorbereitung für robustere Spaltenzugriffe durch Einführung einer COLUMN_MAP (noch nicht durchgängig genutzt). +- Modus-Steuerung in main() konsolidiert. +- alignment_demo korrigiert (nur noch für Hauptblatt). Header für Contacts-Blatt in process_contact_research gesetzt. +- Konstanten für Dateinamen eingeführt. """ import os @@ -30,7 +34,6 @@ import unicodedata import csv import gender_guesser.detector as gender from urllib.parse import urlparse, urlencode -from difflib import SequenceMatcher import argparse # Optional: tiktoken für Token-Zählung (Modus 8) @@ -39,2487 +42,2293 @@ try: except ImportError: tiktoken = None +# ==================== KONSTANTEN ==================== +CREDENTIALS_FILE = "service_account.json" +API_KEY_FILE = "api_key.txt" +SERP_API_KEY_FILE = "serpApiKey.txt" +GENDERIZE_API_KEY_FILE = "genderize_API_Key.txt" +BRANCH_MAPPING_FILE = "ziel_Branchenschema.csv" +LOG_DIR = "Log" + # ==================== KONFIGURATION ==================== class Config: - VERSION = "v1.5.8" + VERSION = "v1.6.0" LANG = "de" - CREDENTIALS_FILE = "service_account.json" SHEET_URL = "https://docs.google.com/spreadsheets/d/1u_gHr9JUfmV1-iviRzbSe3575QEp7KLhK5jFV_gJcgo" MAX_RETRIES = 3 RETRY_DELAY = 5 - LOG_CSV = "gpt_antworten_log.csv" + LOG_CSV = "gpt_antworten_log.csv" # Wird dieser Log noch verwendet? Ggf. entfernen. SIMILARITY_THRESHOLD = 0.65 DEBUG = True WIKIPEDIA_SEARCH_RESULTS = 5 HTML_PARSER = "html.parser" BATCH_SIZE = 10 - TOKEN_MODEL = "gpt-3.5-turbo" + TOKEN_MODEL = "gpt-3.5-turbo" # Oder "gpt-4" etc. + + # Zentrales API-Key-Management + API_KEYS = {} + + @classmethod + def load_api_keys(cls): + cls.API_KEYS['openai'] = cls._load_key_from_file(API_KEY_FILE) + cls.API_KEYS['serpapi'] = cls._load_key_from_file(SERP_API_KEY_FILE) + cls.API_KEYS['genderize'] = cls._load_key_from_file(GENDERIZE_API_KEY_FILE) + # Set OpenAI Key globally if loaded + if cls.API_KEYS.get('openai'): + openai.api_key = cls.API_KEYS['openai'] + else: + debug_print("⚠️ OpenAI API Key konnte nicht geladen werden.") + + @staticmethod + def _load_key_from_file(filepath): + try: + with open(filepath, "r") as f: + return f.read().strip() + except Exception as e: + debug_print(f"Fehler beim Lesen des API-Keys aus '{filepath}': {e}") + return None + +# Globales Mapping-Dictionary und Schema-String +BRANCH_MAPPING = {} +TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfügbar." +ALLOWED_TARGET_BRANCHES = [] + +# Globales Spalten-Mapping (Beispiel basierend auf Zeile 4 - Kurze Beschreibung) +# TODO: Dieses Mapping vervollständigen und durchgängig verwenden! +COLUMN_MAP = { + "ReEval Flag": 0, + "CRM Name": 1, + "CRM Kurzform": 2, + "CRM Website": 3, + "CRM Ort": 4, + "CRM Beschreibung": 5, # Index 5, nicht 6 + "CRM Branche": 6, # Index 6 + "CRM Beschreibung Branche extern": 7, # Index 7 + "CRM Anzahl Techniker": 8, # Index 8 + "CRM Umsatz": 9, # Index 9 + "CRM Anzahl Mitarbeiter": 10, # Index 10 + "CRM Vorschlag Wiki URL": 11, # Index 11 + "Wiki URL": 12, # Index 12 + "Wiki Absatz": 13, # Index 13 + "Wiki Branche": 14, # Index 14 + "Wiki Umsatz": 15, # Index 15 + "Wiki Mitarbeiter": 16, # Index 16 + "Wiki Kategorien": 17, # Index 17 + "Chat Wiki Konsistenzprüfung": 18, # S + "Chat Begründung Wiki Inkonsistenz": 19, # T + "Chat Vorschlag Wiki Artikel": 20, # U + "Begründung bei Abweichung": 21, # V (ungenutzt?) + "Chat Vorschlag Branche": 22, # W + "Chat Konsistenz Branche": 23, # X + "Chat Begründung Abweichung Branche": 24, # Y + "Chat Prüfung FSM Relevanz": 25, # Z + "Chat Begründung für FSM Relevanz": 26, # AA + "Chat Schätzung Anzahl Mitarbeiter": 27, # AB + "Chat Konsistenzprüfung Mitarbeiterzahl": 28, # AC + "Chat Begründung Abweichung Mitarbeiterzahl": 29, # AD + "Chat Einschätzung Anzahl Servicetechniker": 30, # AE + "Chat Begründung Abweichung Anzahl Servicetechniker": 31, # AF + "Chat Schätzung Umsatz": 32, # AG + "Chat Begründung Abweichung Umsatz": 33, # AH + "Linked Serviceleiter gefunden": 34, # AI + "Linked It-Leiter gefunden": 35, # AJ + "Linked Management gefunden": 36, # AK + "Linked Disponent gefunden": 37, # AL + "Contact Search Timestamp": 38, # AM + "Wikipedia Timestamp": 39, # AN + "Timestamp letzte Prüfung": 40, # AO + "Version": 41, # AP + "Tokens": 42, # AQ + "Website Rohtext": 43, # AR + "Website Zusammenfassung": 44 # AS +} + # ==================== RETRY-DECORATOR ==================== def retry_on_failure(func): def wrapper(*args, **kwargs): + func_name = func.__name__ + # Versuche, das 'self' Argument für Methoden zu extrahieren + self_arg = args[0] if args and hasattr(args[0], func_name) else None + effective_func_name = f"{self_arg.__class__.__name__}.{func_name}" if self_arg else func_name + for attempt in range(Config.MAX_RETRIES): try: return func(*args, **kwargs) except Exception as e: - print(f"⚠️ Fehler bei {func.__name__} (Versuch {attempt+1}): {str(e)[:100]}") - time.sleep(Config.RETRY_DELAY) - return None + error_msg = str(e) + # Spezifische Fehlerbehandlung (Beispiel) + if isinstance(e, gspread.exceptions.APIError): + if e.response.status_code == 429: # Rate Limit + wait_time = Config.RETRY_DELAY * (attempt + 1) # Exponential backoff + print(f"🚦 Rate Limit bei {effective_func_name} (Versuch {attempt+1}). Warte {wait_time}s... Fehler: {error_msg[:100]}") + time.sleep(wait_time) + continue # Direkt zum nächsten Versuch + else: + print(f"⚠️ Google API Fehler bei {effective_func_name} (Versuch {attempt+1}): {error_msg[:100]}") + elif isinstance(e, requests.exceptions.RequestException): + print(f"⚠️ Netzwerkfehler bei {effective_func_name} (Versuch {attempt+1}): {error_msg[:100]}") + elif isinstance(e, openai.error.OpenAIError): + print(f"⚠️ OpenAI Fehler bei {effective_func_name} (Versuch {attempt+1}): {error_msg[:100]}") + else: + print(f"⚠️ Unbekannter Fehler bei {effective_func_name} (Versuch {attempt+1}): {type(e).__name__} - {error_msg[:100]}") + + if attempt < Config.MAX_RETRIES - 1: + time.sleep(Config.RETRY_DELAY) + else: + print(f"❌ Endgültiger Fehler bei {effective_func_name} nach {Config.MAX_RETRIES} Versuchen.") + return None # Oder eine spezifische Fehlerkennung zurückgeben + return None # Sollte nicht erreicht werden, aber zur Sicherheit return wrapper # ==================== LOGGING & HELPER FUNCTIONS ==================== +LOG_FILE = None # Wird in main() gesetzt + +def create_log_filename(mode): + if not os.path.exists(LOG_DIR): + os.makedirs(LOG_DIR) + now = datetime.now().strftime("%d-%m-%Y_%H-%M") + ver_short = Config.VERSION.replace(".", "") + return os.path.join(LOG_DIR, f"{now}_{ver_short}_Modus{mode}.txt") + +def debug_print(message): + global LOG_FILE + log_message = f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {message}" + if Config.DEBUG: + print(log_message) + if LOG_FILE: + try: + with open(LOG_FILE, "a", encoding="utf-8") as f: + f.write(log_message + "\n") + except Exception as e: + print(f"[CRITICAL] Log-Schreibfehler: {e}") + + def simple_normalize_url(url): - """ - Normalisiert einen URL-String und gibt nur 'www.domain.tld' zurück. - - Entfernt das Schema (http://, https://) - - Schneidet den Pfad und eventuelle Portinformationen ab - - Fügt 'www.' hinzu, falls es fehlt. - - Args: - url (str): Der Original-URL-String. - - Returns: - str: Normalisierte URL im Format 'www.domain.tld' oder "k.A.", falls etwas fehlschlägt. - """ + """Normalisiert URL zu www.domain.tld oder k.A.""" + if not url or not isinstance(url, str): + return "k.A." + url = url.strip() if not url: return "k.A." # Falls kein Schema vorhanden ist, hinzufügen - if not url.lower().startswith("http"): + if not url.lower().startswith(("http://", "https://")): url = "https://" + url try: - # Entferne das Schema - parts = url.split("://", 1) - domain_part = parts[1] if len(parts) > 1 else parts[0] - # Entferne den Pfad (alles ab dem ersten "/") - domain_part = domain_part.split("/", 1)[0] + parsed = urlparse(url) + domain_part = parsed.netloc # Entferne einen eventuellen Port (z.B. ":8080") domain_part = domain_part.split(":", 1)[0] - # Wenn die Domain nicht mit "www." beginnt, hinzufügen - if not domain_part.lower().startswith("www."): - domain_part = "www." + domain_part - return domain_part + # Wenn die Domain nicht mit "www." beginnt, hinzufügen (außer bei sehr kurzen Domains) + if not domain_part.lower().startswith("www.") and '.' in domain_part: + # Ausnahme für IP-Adressen oder ungewöhnliche Namen ohne TLD + if re.match(r"^\d{1,3}(\.\d{1,3}){3}$", domain_part): + pass # IP-Adresse behalten + else: + domain_part = "www." + domain_part + return domain_part.lower() # Einheitliche Kleinschreibung except Exception as e: + debug_print(f"Fehler bei URL-Normalisierung '{url}': {e}") return "k.A." -# --------------------------------------------------------------------- -# 1. Mapping-Funktion: Laden der Ziel-Branchenschema-Tabelle aus CSV -# --------------------------------------------------------------------- -def load_branch_mapping(file_path="ziel_Branchenschema.csv"): - """ - Lädt die Mapping-Tabelle mit zwei Spalten: - Spalte A: Externer (Wikipedia-)Brancheneintrag (z. B. "Getränkeabfüllung") - Spalte B: Ziel-Branchenschema (z. B. "Hersteller / Produzenten > Getränke") - Gibt ein Dictionary zurück, das alle Einträge (in Lowercase und normalisiert) enthält. - """ - mapping = {} - try: - with open(file_path, encoding="utf-8") as f: - reader = csv.reader(f) - for row in reader: - if len(row) >= 2: - key = row[0].strip().lower() - value = row[1].strip() - if key and value: - mapping[key] = value - except Exception as e: - debug_print("Fehler beim Laden des Branchen-Mappings: " + str(e)) - return mapping - -# Globales Mapping-Dictionary laden -BRANCH_MAPPING = load_branch_mapping() - - -def map_external_branch(external_branch): - """ - Normalisiert den externen Brancheneintrag und sucht im Mapping-Dictionary nach einer - entsprechenden Übersetzung in das Ziel-Branchenschema. - Falls kein exaktes Mapping gefunden wird, erfolgt eine Teilübereinstimmungsprüfung. - """ - norm = normalize_string(external_branch).lower() - if norm in BRANCH_MAPPING: - return BRANCH_MAPPING[norm] - for key in BRANCH_MAPPING: - if key in norm: - return BRANCH_MAPPING[key] - return norm - - -def process_wiki_batch(main_sheet, data, start_row, end_row): - """ - Batch-Prozess für Wikipedia-Verifizierung (Wiki-Modus): - - Verarbeitet alle Zeilen von start_row bis end_row in Gruppen (Batchgröße = Config.BATCH_SIZE). - - Ergebnisse werden in den Spalten S bis Y geschrieben. - """ - batch_size = Config.BATCH_SIZE - batches = [] - row_numbers = [] - for i in range(start_row, end_row + 1): - row = data[i - 1] - entry_text = ( - f"Eintrag {i}:\n" - f"Firmenname: {row[1] if len(row) > 1 else ''}\n" - f"CRM-Beschreibung: {row[7] if len(row) > 7 else ''}\n" - f"Wikipedia-URL: {row[11] if len(row) > 11 and row[11].strip() not in ['', 'k.A.'] else 'k.A.'}\n" - f"Wiki-Absatz: {row[12] if len(row) > 12 else 'k.A.'}\n" - f"Wiki-Kategorien: {row[16] if len(row) > 16 else 'k.A.'}\n" - "-----\n" - ) - batches.append(entry_text) - row_numbers.append(i) - if len(batches) == batch_size: - _process_batch(main_sheet, batches, row_numbers) - batches = [] - row_numbers = [] - if batches: - _process_batch(main_sheet, batches, row_numbers) - debug_print("Wiki batch processing completed.") - - -def process_website_batch(main_sheet, data, start_row, end_row): - """ - Batch-Prozess für Website-Scraping (Website-Modus): - - Für jede Zeile von start_row bis end_row werden Website-Rohtext (get_website_raw) und - Zusammenfassung (summarize_website_content) abgerufen. - - Ergebnisse werden in Spalte AR (Rohtext) und AS (Zusammenfassung) geschrieben. - - Am Ende jeder Zeile wird der Zeitstempel (Spalte AO) und Version (Spalte AP) gesetzt. - """ - for i in range(start_row, end_row + 1): - row = data[i - 1] - website = row[3] if len(row) >= 4 else "" - if website.strip() == "" or website.strip().lower() == "k.a.": - debug_print(f"Zeile {i}: Kein gültiger Website-Eintrag.") - continue - raw_text = get_website_raw(website) - summary = summarize_website_content(raw_text) - try: - main_sheet.update(values=[[raw_text]], range_name=f"AR{i}") - main_sheet.update(values=[[summary]], range_name=f"AS{i}") - current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - main_sheet.update(values=[[current_timestamp]], range_name=f"AO{i}") - main_sheet.update(values=[[Config.VERSION]], range_name=f"AP{i}") - debug_print(f"Zeile {i}: Website-Daten aktualisiert | Zeitstempel: {current_timestamp}, Version: {Config.VERSION}") - except Exception as e: - debug_print(f"Fehler beim Updaten der Website-Daten in Zeile {i}: {e}") - time.sleep(Config.RETRY_DELAY) - debug_print("Website batch processing completed.") - - -def process_branch_batch(main_sheet, data, start_row, end_row): - """ - Batch-Prozess für Brancheneinschätzung (Branch-Modus): - - Für jede Zeile von start_row bis end_row werden relevante Felder ausgelesen und - evaluate_branche_chatgpt aufgerufen. - - Das Ergebnis (Dictionary mit "branch", "consistency", "justification") wird in - Spalte W (Branch), X (Konsistenz) und Y (Begründung) geschrieben. - - Für jede verarbeitete Zeile werden zudem der Zeitstempel (Spalte AO) und Version (Spalte AP) gesetzt. - """ - for i in range(start_row, end_row + 1): - row = data[i - 1] - crm_branche = row[6] if len(row) > 6 else "" - beschreibung = row[7] if len(row) > 7 else "" - wiki_branche = row[14] if len(row) > 14 else "" - wiki_kategorien = row[17] if len(row) > 17 else "" - website_summary = row[44] if len(row) > 44 else "" - result = evaluate_branche_chatgpt(crm_branche, beschreibung, wiki_branche, wiki_kategorien, website_summary) - try: - main_sheet.update(values=[[result["branch"]]], range_name=f"W{i}") - main_sheet.update(values=[[result["consistency"]]], range_name=f"X{i}") - main_sheet.update(values=[[result["justification"]]], range_name=f"Y{i}") - current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - main_sheet.update(values=[[current_timestamp]], range_name=f"AO{i}") - main_sheet.update(values=[[Config.VERSION]], range_name=f"AP{i}") - debug_print(f"Zeile {i}: Branch-Einschätzung aktualisiert: {result} | Zeitstempel: {current_timestamp}, Version: {Config.VERSION}") - except Exception as e: - debug_print(f"Fehler beim Updaten der Branch-Daten in Zeile {i}: {e}") - time.sleep(Config.RETRY_DELAY) - debug_print("Branch batch processing completed.") - - -def run_dispatcher(mode, row_limit=None): - debug_print(f"Starte Dispatcher im Modus '{mode}' mit row_limit={row_limit}.") - gc = gspread.authorize(ServiceAccountCredentials.from_json_keyfile_name( - Config.CREDENTIALS_FILE, ["https://www.googleapis.com/auth/spreadsheets"])) - sh = gc.open_by_url(Config.SHEET_URL) - main_sheet = sh.sheet1 - data = main_sheet.get_all_values() - start_row = None - for i in range(7, len(data) + 1): - row = data[i - 1] - if len(row) < 41 or row[40].strip() == "": - start_row = i - break - if start_row is None: - debug_print("Keine Zeile ohne Zeitstempel in Spalte AO gefunden. Dispatcher beendet.") - return - debug_print(f"Dispatcher: Verarbeitung startet ab Zeile {start_row}.") - if row_limit is not None: - end_row = start_row + row_limit - 1 - else: - end_row = len(data) - debug_print(f"Dispatcher: Es werden Zeilen {start_row} bis {end_row} bearbeitet.") - - if mode == "wiki": - process_wiki_batch(main_sheet, data, start_row, end_row) - elif mode == "website": - process_website_batch(main_sheet, data, start_row, end_row) - elif mode == "branch": - process_branch_batch(main_sheet, data, start_row, end_row) - elif mode == "combined": - process_wiki_batch(main_sheet, data, start_row, end_row) - process_website_batch(main_sheet, data, start_row, end_row) - process_branch_batch(main_sheet, data, start_row, end_row) - else: - debug_print("Ungültiger Modus im Dispatcher.") - - def normalize_string(s): - """ - Normalisiert Sonderzeichen in einem String anhand eines umfangreichen Mappings. - Ersetzt beispielsweise: - - Deutsche Umlaute: ü -> ue, ö -> oe, ä -> ae, ß -> ss - - Verschiedene diakritische Zeichen: č, ć -> c; š -> s; ž -> z; etc. - - Auch weitere Buchstaben mit Akzenten werden konvertiert. - """ + """Normalisiert Umlaute und Sonderzeichen.""" + if not s or not isinstance(s, str): + return "" replacements = { - # Deutsche Umlaute und spezielle Buchstaben - 'Ä': 'Ae', 'Ö': 'Oe', 'Ü': 'Ue', 'ß': 'ss', - 'ä': 'ae', 'ö': 'oe', 'ü': 'ue', - # Lateinische Buchstaben mit Akzenten - 'À': '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', - # Zusätzliche spezifische Buchstaben - 'Đ': 'D', 'đ': 'd', - 'č': 'c', 'Č': 'C', 'ć': 'c', 'Ć': 'C', - 'ł': 'l', 'Ł': 'L', - 'ğ': 'g', 'Ğ': 'G', - 'ş': 's', 'Ş': 'S', - # Weitere diakritische Zeichen (z. B. aus osteuropäischen Sprachen) - 'ă': 'a', 'Ă': 'A', - 'ı': 'i', 'İ': 'I', - 'ň': 'n', 'Ň': 'N', - 'ř': 'r', 'Ř': 'R', - 'ő': 'o', 'Ű': 'U', 'ű': 'u', 'Ű': 'U', - 'ț': 't', 'Ț': 'T', - 'ș': 's', 'Ș': 'S' + 'Ä': '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 (kann einige Akzente entfernen) + try: + # Versuche NFKD Normalisierung, um Kompatibilitätszeichen zu zerlegen + s = unicodedata.normalize('NFKD', s).encode('ascii', 'ignore').decode('ascii') + except: + # Fallback, wenn NFKD fehlschlägt (sollte selten sein) + pass + # Dann manuelle Ersetzungen for src, target in replacements.items(): s = s.replace(src, target) return s -def get_gender(firstname): - """ - Ermittelt das Geschlecht anhand des Vornamens. - Zunächst wird gender-guesser genutzt. Ergibt sich ein unsicheres Ergebnis ("andy" oder "unknown"), - so wird als Fallback die Genderize API (mit API-Key aus der Datei "genderize_API_Key.txt") angefragt. - """ - d = gender.Detector() - result = d.get_gender(firstname) - if result in ["andy", "unknown"]: - try: - with open("genderize_API_Key.txt", "r") as f: - genderize_api_key = f.read().strip() - except Exception as e: - debug_print("Fehler beim Lesen des Genderize API-Schlüssels: " + str(e)) - return result - params = {"name": firstname, "apikey": genderize_api_key} - try: - response = requests.get("https://api.genderize.io", params=params, timeout=10) - data = response.json() - new_gender = data.get("gender") - if new_gender: - return new_gender - else: - return result - except Exception as e: - debug_print("Fehler bei der Genderize API-Anfrage: " + str(e)) - return result +def clean_text(text): + """Bereinigt Text von Wikipedia etc.""" + if not text: + return "k.A." + try: + text = str(text) # Sicherstellen, dass es ein String ist + text = unicodedata.normalize("NFKC", text) # Normalisiert Whitespace, Ligaturen etc. + text = re.sub(r'\[\d+\]', '', text) # Entfernt [1], [2] etc. + text = re.sub(r'\s+', ' ', text).strip() # Reduziert multiple Leerzeichen + return text if text else "k.A." + except Exception as e: + debug_print(f"Fehler bei clean_text: {e}") + return "k.A." + + +def normalize_company_name(name): + """Entfernt Rechtsformzusätze etc. für Vergleiche.""" + if not name: return "" + name = clean_text(name) # Vorab bereinigen + # Umfassendere Liste von Rechtsformen und Zusätzen + 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', + # Zusätzliche generische Begriffe am Ende + 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 für ganze Wörter (case-insensitive) + pattern = r'\b(' + '|'.join(forms) + r')\b' + normalized = re.sub(pattern, '', name, flags=re.IGNORECASE) + + # Interpunktion entfernen/ersetzen (außer evtl. &) + normalized = re.sub(r'[.,;:]', '', normalized) + normalized = re.sub(r'[\-–/]', ' ', normalized) # Bindestriche etc. durch Leerzeichen ersetzen + normalized = re.sub(r'\s+', ' ', normalized).strip() # Multiple Leerzeichen reduzieren + + return normalized.lower() + + +def extract_numeric_value(raw_value, is_umsatz=False): + """Extrahiert und normalisiert Zahlenwerte (Umsatz in Mio, Mitarbeiter).""" + if not raw_value or not isinstance(raw_value, str): return "k.A." + raw_value = clean_text(raw_value) + if raw_value == "k.A.": return "k.A." + + # Entferne Präfixe wie ca., über, etc. und Währungssymbole (€, $, etc.) und Punkte als Tausendertrenner + processed_value = re.sub(r'(?i)\b(ca\.?|circa|über|unter|rund|etwa|mehr als|weniger als|bis zu)\b', '', raw_value) + processed_value = re.sub(r'[€$£¥]', '', processed_value) + processed_value = processed_value.replace('.', '') # Tausenderpunkte entfernen + processed_value = processed_value.replace(',', '.') # Komma als Dezimaltrenner + + # Finde die erste Zahl (kann Dezimalpunkt enthalten) + match = re.search(r'([\d\.]+)', processed_value) + if not match: + debug_print(f"Keine numerischen Zeichen gefunden in Rohtext: '{raw_value}'") + return "k.A." + + num_str = match.group(1) + try: + num = float(num_str) + except ValueError: + debug_print(f"Fehler bei Float-Umwandlung von '{num_str}' (aus '{raw_value}')") + return "k.A." # Gib k.A. zurück, wenn die Zahl selbst ungültig ist + + # Multiplikatoren anwenden (Groß/Kleinschreibung ignorieren) + raw_lower = raw_value.lower() + multiplier = 1.0 + if "mrd" in raw_lower or "milliarden" in raw_lower or "billion" in raw_lower: # Englisch Billion = Deutsch Milliarde + multiplier = 1000.0 # Für Umsatz: Ergebnis wird in Mio sein + elif "mio" in raw_lower or "millionen" in raw_lower or "mill." in raw_lower: + multiplier = 1.0 # Für Umsatz: Ergebnis ist bereits in Mio + elif "tsd" in raw_lower or "tausend" in raw_lower: + multiplier = 0.001 # Für Umsatz: Umrechnung Tausend in Mio + + num = num * multiplier + + if is_umsatz: + # Umsatz immer auf Millionen runden (Ganzzahl) + return str(int(round(num))) else: + # Mitarbeiter als Ganzzahl + return str(int(round(num))) + +def get_gender(firstname): + """Ermittelt Geschlecht via gender-guesser und Fallback Genderize API.""" + if not firstname or not isinstance(firstname, str): return "unknown" + firstname = firstname.strip().split(" ")[0] # Nur ersten Teil des Vornamens verwenden + if not firstname: return "unknown" + + d = gender.Detector(case_sensitive=False) + result = d.get_gender(firstname, 'germany') # Land hinzufügen kann helfen + if result in ["andy", "unknown", "mostly_male", "mostly_female"]: + genderize_key = Config.API_KEYS.get('genderize') + if not genderize_key: + debug_print("Genderize API-Schlüssel nicht verfügbar, Fallback nicht möglich.") + return result if result not in ["andy", "unknown"] else "unknown" # Gib mostly_ zurück + + params = {"name": firstname, "apikey": genderize_key, "country_id": "DE"} + try: + response = requests.get("https://api.genderize.io", params=params, timeout=5) + response.raise_for_status() # Fehler bei HTTP-Status != 200 + data = response.json() + # Genderize gibt 'male'/'female' oder null zurück + api_gender = data.get("gender") + probability = data.get("probability", 0) + if api_gender and probability > 0.6: # Nur bei ausreichender Sicherheit übernehmen + return api_gender + else: + # Wenn Genderize unsicher ist, behalte das Ergebnis von gender-guesser, wenn es "mostly_" war + return result if result not in ["andy", "unknown"] else "unknown" + except requests.exceptions.RequestException as e: + debug_print(f"Fehler bei der Genderize API-Anfrage für '{firstname}': {e}") + return result if result not in ["andy", "unknown"] else "unknown" + except Exception as e: + debug_print(f"Allgemeiner Fehler bei Genderize für '{firstname}': {e}") + return result if result not in ["andy", "unknown"] else "unknown" + else: # male, female return result def get_email_address(firstname, lastname, website): - """ - Generiert eine E-Mail-Adresse im Format vorname.nachname@domain.tld. - Dabei wird der Domainname aus der Website extrahiert und Vor- und Nachname - zunächst mittels normalize_string normalisiert. - """ - # Falls Website nicht mit http/https beginnt, Protokoll hinzufügen - url = website if website.startswith("http") else "http://" + website - parsed = urlparse(url) - domain = parsed.netloc + """Generiert E-Mail: vorname.nachname@domain.tld.""" + if not all([firstname, lastname, website]) or not all(isinstance(x, str) for x in [firstname, lastname, website]): + return "" + + domain = simple_normalize_url(website) + if domain == "k.A." or not '.' in domain: # Einfache Domain-Validierung + return "" + + # Domain von 'www.' befreien, falls simple_normalize_url es nicht schon getan hat if domain.startswith("www."): domain = domain[4:] - # Vor- und Nachname normalisieren, in Kleinbuchstaben umwandeln und nicht-alphanumerische Zeichen entfernen - normalized_first = re.sub(r'\W+', '', normalize_string(firstname.lower())) - normalized_last = re.sub(r'\W+', '', normalize_string(lastname.lower())) - if normalized_first and normalized_last: + + # Vor- und Nachname normalisieren (Umlaute etc.), Kleinbuchstaben, keine Sonderzeichen außer '.' und '-' erlauben + normalized_first = normalize_string(firstname.lower()) + normalized_last = normalize_string(lastname.lower()) + # Ersetze Leerzeichen und mehrere Bindestriche durch einen einzelnen Bindestrich + normalized_first = re.sub(r'\s+', '-', normalized_first) + normalized_last = re.sub(r'\s+', '-', normalized_last) + # Entferne alle Zeichen, die nicht alphanumerisch oder Bindestrich sind + normalized_first = re.sub(r'[^\w\-]+', '', normalized_first) + normalized_last = re.sub(r'[^\w\-]+', '', normalized_last) + + if normalized_first and normalized_last and domain: return f"{normalized_first}.{normalized_last}@{domain}" else: return "" -def is_valid_company_article(wiki_categories): - """ - Prüft, ob in den Wikipedia-Kategorien ein Hinweis auf einen Unternehmensartikel enthalten ist. - Wir suchen nach den Stichwörtern 'unternehmen', 'firma', 'betrieb' und 'konzern'. - - Args: - wiki_categories (str): Die Liste oder der String der Wikipedia-Kategorien. - - Returns: - bool: True, wenn eines der Keywords gefunden wird, sonst False. - """ - if wiki_categories == "k.A.": - return False - keywords = ["unternehmen", "firma", "betrieb", "konzern"] - wiki_cats_lower = wiki_categories.lower() - for word in keywords: - if word in wiki_cats_lower: - return True - return False - -def load_target_schema(csv_filepath="ziel_Branchenschema.csv"): - """ - Baut das Ziel-Branchenschema als String aus der CSV-Datei. - Gibt ein Tupel zurück: (mapping, schema_string, allowed_targets) - """ - mapping, allowed_branches = load_target_branches(csv_filepath) - if allowed_branches: - schema_string = ( - "Ziel-Branchenschema: Folgende Branchenbereiche sind gültig:\n" + - "\n".join(f"- {branch}" for branch in allowed_branches) + - "\nBitte ordne das Unternehmen ausschließlich in einen dieser Bereiche ein." - ) - return mapping, schema_string, allowed_branches - else: - return {}, "Ziel-Branchenschema nicht verfügbar.", [] - - -def serp_website_lookup(company_name): - """ - Ermittelt über SERPAPI (Google-Suche) die Website zum Unternehmen. - - Verwendet als Query den Firmennamen mit dem Zusatz "Website". - - Filtert Ergebnisse anhand einer Blacklist (z.B. bloomberg.com, northdata.de, finanzen.net, handelsblatt.com). - - Gibt die normalisierte Website-URL (im Format "www.domain.tld") zurück. - - Returns: - str: Normalisierte Website-URL oder "k.A.", falls kein passendes Ergebnis gefunden wurde. - """ - # Blacklist unerwünschter Domains - blacklist = ["bloomberg.com", "northdata.de", "finanzen.net", "handelsblatt.com"] - try: - with open("serpApiKey.txt", "r") as f: - serp_key = f.read().strip() - except Exception as e: - debug_print(f"Fehler beim Lesen des SerpAPI-Schlüssels: {e}") - return "k.A." - - query = f"{company_name} Website" - params = { - "engine": "google", - "q": query, - "api_key": serp_key, - "hl": "de" - } - try: - response = requests.get("https://serpapi.com/search", params=params, timeout=10) - data = response.json() - if "organic_results" in data: - for result in data["organic_results"]: - url = result.get("link", "") - # Überprüfen, ob die URL nicht in der Blacklist enthalten ist - if url and not any(bad in url for bad in blacklist): - normalized_url = simple_normalize_url(url) - debug_print(f"SERP-Website Lookup: Gefundene Website '{normalized_url}' für {company_name}") - return normalized_url - return "k.A." - except Exception as e: - debug_print(f"Fehler beim SERP-API Website Lookup für {company_name}: {e}") - return "k.A." - -def create_log_filename(mode): - now = datetime.now().strftime("%d-%m-%Y_%H-%M") - ver_short = Config.VERSION.replace(".", "") - return os.path.join("Log", f"{now}_{ver_short}_Modus{mode}.txt") - -if not os.path.exists("Log"): - os.makedirs("Log") -LOG_FILE = None - -def debug_print(message): - global LOG_FILE - if Config.DEBUG: - print(f"[DEBUG] {message}") - if LOG_FILE: - try: - with open(LOG_FILE, "a", encoding="utf-8") as f: - f.write(f"[DEBUG] {datetime.now().isoformat()} - {message}\n") - except Exception as e: - print(f"[DEBUG] Log-Schreibfehler: {e}") - -def clean_text(text): - if not text: - return "k.A." - text = unicodedata.normalize("NFKC", str(text)) - text = re.sub(r'\[\d+\]', '', text) - text = re.sub(r'\s+', ' ', text).strip() - return text if text else "k.A." - -def normalize_company_name(name): - if not name: - return "" - forms = [ - r'gmbh', r'g\.m\.b\.h\.', r'ug', r'u\.g\.', r'ug \(haftungsbeschränkt\)', - r'u\.g\. \(haftungsbeschränkt\)', r'ag', r'a\.g\.', r'ohg', r'o\.h\.g\.', - r'kg', r'k\.g\.', r'gmbh & co\.?\s*kg', r'g\.m\.b\.h\. & co\.?\s*k\.g\.', - r'ag & co\.?\s*kg', r'a\.g\. & co\.?\s*k\.g\.', r'e\.k\.', r'e\.kfm\.', - r'e\.kfr\.', r'ltd\.', r'ltd & co\.?\s*kg', r's\.a r\.l\.', r'stiftung', - r'genossenschaft', r'ggmbh', r'gug', r'partg', r'partgmbb', r'kgaa', r'se', - r'og', r'o\.g\.', r'e\.u\.', r'ges\.n\.b\.r\.', r'genmbh', r'verein', - r'kollektivgesellschaft', r'kommanditgesellschaft', r'einzelfirma', r'sàrl', - r'sa', r'sagl', r'gmbh & co\.?\s*ohg', r'ag & co\.?\s*ohg', r'gmbh & co\.?\s*kgaa', - r'ag & co\.?\s*kgaa', r's\.a\.', r's\.p\.a\.', r'b\.v\.', r'n\.v\.' - ] - pattern = r'\b(' + '|'.join(forms) + r')\b' - normalized = re.sub(pattern, '', name, flags=re.IGNORECASE) - normalized = re.sub(r'[\-–]', ' ', normalized) - normalized = re.sub(r'\s+', ' ', normalized).strip() - return normalized.lower() - -def extract_numeric_value(raw_value, is_umsatz=False): - raw_value = raw_value.strip() - if not raw_value: - return "k.A." - raw_value = re.sub(r'\b(ca\.?|circa|über)\b', '', raw_value, flags=re.IGNORECASE) - raw = raw_value.lower().replace("\xa0", " ") - match = re.search(r'([\d.,]+)', raw, flags=re.UNICODE) - if not match or not match.group(1).strip(): - debug_print(f"Keine numerischen Zeichen gefunden im Rohtext: '{raw_value}'") - return "k.A." - num_str = match.group(1) - if ',' in num_str: - num_str = num_str.replace('.', '').replace(',', '.') - try: - num = float(num_str) - except Exception as e: - debug_print(f"Fehler bei der Umwandlung von '{num_str}' (Rohtext: '{raw_value}'): {e}") - return raw_value - else: - num_str = num_str.replace(' ', '').replace('.', '') - try: - num = float(num_str) - except Exception as e: - debug_print(f"Fehler bei der Umwandlung von '{num_str}' (Rohtext: '{raw_value}'): {e}") - return raw_value - if is_umsatz: - if "mrd" in raw or "milliarden" in raw: - num *= 1000 - elif "mio" in raw or "millionen" in raw: - pass - else: - num /= 1e6 - return str(int(round(num))) - else: - return str(int(round(num))) - -def compare_umsatz_values(crm, wiki): - debug_print(f"Vergleich CRM Umsatz: '{crm}' mit Wikipedia Umsatz: '{wiki}'") - try: - crm_val = float(crm) - wiki_val = float(wiki) - except Exception as e: - debug_print(f"Fehler beim Umwandeln der Werte: CRM='{crm}', Wiki='{wiki}': {e}") - return "Daten unvollständig" - if crm_val == 0: - return "CRM Umsatz 0" - diff = abs(crm_val - wiki_val) / crm_val - if diff < 0.1: - return "OK" - else: - diff_mio = abs(crm_val - wiki_val) - return f"Abweichung: {int(round(diff_mio))} Mio €" - -def is_valid_branch(branch): - """ - Prüft, ob der übergebene Branch-String grundsätzlich gültig ist. - Gültig ist ein String, der: - - nicht leer und nicht "k.A." (unabhängig von Groß-/Kleinschreibung) ist, - - mindestens ein hierarchisches Trennzeichen ">" enthält. - """ - if not branch or branch.strip() == "": - return False - if branch.strip().lower() == "k.a.": - return False - if ">" not in branch: - return False - return True - -def branch_matches_target_schema(branch): - """ - Überprüft, ob der Branch zum Ziel-Branchenschema passt. - Als Fokus werden die definierten Präfixe verwendet (laut Alignment Demo): - - "service provider" - - "hersteller / produzenten" - - "sonstige" - """ - allowed_prefixes = [ - "service provider", - "hersteller / produzenten", - "sonstige" - ] - branch_lower = branch.lower() - for prefix in allowed_prefixes: - if branch_lower.startswith(prefix): - return True - return False - -def extract_suffix(branch): - """ - Extrahiert den Teil hinter dem ">" als Suffix. - Falls kein ">" vorhanden ist, wird der gesamte String zurückgegeben. - """ - if ">" in branch: - return branch.split(">")[-1].strip() - return branch.strip() - -def merge_with_prefix(suggestion, crm_branch): - """ - Falls der ChatGPT-Vorschlag kein ">" enthält, wird der Präfix aus dem CRM-Branchenwert übernommen. - Beispiel: CRM: "Hersteller / Produzenten > Automaten (Vending, Slot)" und - Vorschlag: "Automaten (Vending, Slot)" ergeben "Hersteller / Produzenten > Automaten (Vending, Slot)". - """ - if ">" in crm_branch: - prefix = crm_branch.split(">")[0].strip() - return prefix + " > " + suggestion.strip() - return suggestion.strip() - def fuzzy_similarity(str1, str2): - """ - Berechnet die Ähnlichkeit zweier Strings als Wert zwischen 0 und 1. - """ - return SequenceMatcher(None, str1, str2).ratio() + """Berechnet Ähnlichkeit zwischen 0 und 1.""" + if not str1 or not str2: return 0.0 + return SequenceMatcher(None, str(str1).lower(), str(str2).lower()).ratio() -# ==================== TOKEN COUNT FUNCTION ==================== -def token_count(text): - if tiktoken: - try: - enc = tiktoken.encoding_for_model(Config.TOKEN_MODEL) - return len(enc.encode(text)) - except Exception as e: - debug_print(f"Fehler beim Token-Counting mit tiktoken: {e}") - return len(text.split()) - else: - return len(text.split()) +# ==================== BRANCH MAPPING & SCHEMA ==================== -# ==================== PROMPT-ÜBERSICHT ==================== -def prompt_overview(): - prompts = [ - ["Funktion", "Verwendeter Prompt"], - ["process_wiki_verification", "Bitte verifiziere den Wikipedia-Artikel für {company_name}. Wenn 'k.A.' vorliegt, suche mit den vorliegenden Informationen nach einem passenden Artikel. (Nur 'Skipped (k.A.)', wenn wirklich keine Daten gefunden werden.)"], - ["process_employee_estimation", "Schätze die Mitarbeiterzahl für {company_name} basierend auf Wikipedia-Daten. Bei 'k.A.' liefere 'Skipped (k.A.)'."], - ["process_employee_consistency", "Vergleiche CRM-, Wiki- und ChatGPT-Mitarbeiterzahlen. Gib die prozentuale Differenz und eine Begründung zurück."], - ["evaluate_umsatz_chatgpt", "Schätze den Umsatz in Mio. Euro für {company_name} basierend auf Wikipedia-Daten, antworte nur mit der Zahl."], - ["evaluate_fsm_suitability", "Bewerte, ob {company_name} für Field Service Management geeignet ist; antworte ausschließlich mit 'Ja' oder 'Nein' und einer kurzen Begründung."], - ["evaluate_branche_chatgpt", "Ordne {company_name} exakt einer Branche des Ziel-Branchenschemas zu. Antworte im Format:\nBranche: \nÜbereinstimmung: \nBegründung: ."] - ] - return prompts - -# ==================== TIMESTAMP HANDLING ==================== -processed_timestamps = {} -def should_process(field): - return field not in processed_timestamps -def mark_processed(field): - processed_timestamps[field] = datetime.now().isoformat() - -# ==================== NEUE FUNKTION: Website-Rohtext extrahieren ==================== -def get_website_raw(url, max_length=1000, verify_cert=False): - """ - Ruft die Website ab und gibt den bereinigten Text des -Inhalts zurück (maximal max_length Zeichen). - Zusätzliche Verbesserungen: - - Falls kein Schema vorhanden ist, wird "https://" ergänzt. - - Es werden zusätzliche Header (insbesondere ein User-Agent) mitgeschickt. - - Optional wird die Zertifikatüberprüfung deaktiviert (verify_cert=False). - - Args: - url (str): Die URL der Website. - max_length (int): Maximale Länge des zurückgegebenen Texts. - verify_cert (bool): Gibt an, ob SSL-Zertifikate verifiziert werden sollen. - - Returns: - str: Extrahierter Text oder "k.A.", wenn Fehler auftreten. - """ - if not url.lower().startswith("http"): - url = "https://" + url - headers = { - "User-Agent": "Mozilla/5.0 (compatible; AcmeInc/1.0; +http://example.com/bot)" - } - try: - response = requests.get(url, timeout=10, headers=headers, verify=verify_cert) - if response.status_code != 200: - debug_print(f"Fehler: Website {url} lieferte Statuscode {response.status_code}") - return "k.A." - soup = BeautifulSoup(response.text, Config.HTML_PARSER) - body = soup.find('body') - if body: - text = body.get_text(separator=' ', strip=True) - text = re.sub(r'\s+', ' ', text) - result = text[:max_length] - debug_print(f"Website {url} erfolgreich gescrapt. Extrahierter Text (Länge {len(result)}): {result[:100]}...") - return result - else: - debug_print(f"Kein -Tag gefunden in {url}") - return "k.A." - except Exception as e: - debug_print(f"Fehler beim Abrufen der Website {url}: {e}") - return "k.A." - - -# ==================== NEUE FUNKTION: Website-Zusammenfassung erstellen ==================== -def summarize_website_content(raw_text): - if raw_text == "k.A." or raw_text.strip() == "": - return "k.A." - prompt = ( - "Fasse den folgenden Text der Unternehmensstartseite zusammen. " - "Beschreibe kurz das Tätigkeitsfeld, die Produkte und Leistungen des Unternehmens:\n\n" - f"{raw_text}\n\n" - "Zusammenfassung:" - ) - try: - with open("api_key.txt", "r") as f: - api_key = f.read().strip() - except Exception as e: - debug_print(f"Fehler beim Lesen des API-Tokens für Website-Zusammenfassung: {e}") - return "k.A." - openai.api_key = api_key - try: - response = openai.ChatCompletion.create( - model=Config.TOKEN_MODEL, - messages=[{"role": "user", "content": prompt}], - temperature=0.3 - ) - result = response.choices[0].message.content.strip() - return result - except Exception as e: - debug_print(f"Fehler beim Erstellen der Website-Zusammenfassung: {e}") - return "k.A." - -# ==================== NEUE FUNKTION: Website-Suche bei fehlender Website ==================== -# Neue Funktion: SERP-API Website Lookup in DataProcessor -class DataProcessor: - def __init__(self): - self.sheet_handler = GoogleSheetHandler() - self.wiki_scraper = WikipediaScraper() - - def process_serp_website_lookup(self): - debug_print("Starte SERP-API Website Lookup für alle Zeilen ohne CRM-Website (Spalte D).") - for i, row in enumerate(self.sheet_handler.sheet_values[1:], start=2): - current_website = row[3] if len(row) > 3 else "" - if current_website.strip() == "": - company_name = row[1] if len(row) > 1 else "" - new_website = serp_website_lookup(company_name) - if new_website != "k.A.": - self.sheet_handler.sheet.update(values=[[new_website]], range_name=f"D{i}") - debug_print(f"Zeile {i}: Neue Website gefunden und in Spalte D eingetragen: {new_website}") - else: - debug_print(f"Zeile {i}: Keine Website gefunden für {company_name}.") - time.sleep(Config.RETRY_DELAY) - else: - debug_print(f"Zeile {i}: CRM-Website bereits vorhanden, Überspringe.") - - # Bestehende Funktion, die alle Zeilen verarbeitet - def process_rows(self, num_rows=None): - global MODE - if MODE == "1": - self.process_rows_complete() # Vollständige Verarbeitung (sofern definiert) - elif MODE == "11": - # Re-Evaluation markierter Zeilen (nur 'x' in Spalte A) - for i, row in enumerate(self.sheet_handler.sheet_values[1:], start=2): - if row[0].strip().lower() == "x": - self._process_single_row(i, row) - elif MODE == "21": - # Testmodus: Nur Website-Scraping - for i, row in enumerate(self.sheet_handler.sheet_values[1:], start=2): - self._process_single_row(i, row, process_wiki=False, process_chatgpt=False) - elif MODE == "22": - # SERP-API Website Lookup - self.process_serp_website_lookup() - elif MODE == "31": - # Nur ChatGPT-Auswertung - for i, row in enumerate(self.sheet_handler.sheet_values[1:], start=2): - self._process_single_row(i, row, process_wiki=False, process_chatgpt=True) - elif MODE == "41": - # Nur Wikipedia-Scraping - for i, row in enumerate(self.sheet_handler.sheet_values[1:], start=2): - self._process_single_row(i, row, process_wiki=True, process_chatgpt=False) - elif MODE == "51": - process_verification_only() - elif MODE == "6": - process_contact_research() - elif MODE == "8": - process_batch_token_count() - else: - start_index = self.sheet_handler.get_start_index() - print(f"Starte bei Zeile {start_index+1}") - rows_processed = 0 - for i, row in enumerate(self.sheet_handler.sheet_values[1:], start=2): - if i < start_index: - continue - if num_rows is not None and rows_processed >= num_rows: - break - self._process_single_row(i, row) - rows_processed += 1 - - -# ==================== NEUE FUNKTION: process_verification_only ==================== -def process_verification_only(): - """ - Überarbeiteter Batch‑Prozess (Version 1.5.10, Modus 51): - - Startet die Verarbeitung ab Zeile 7 und sucht ab dort die erste Zeile, in der Spalte AO (Index 41) leer ist. - - Falls der Nutzer eine Gesamtzeilenanzahl eingibt, die vor dem Startpunkt liegt, - wird dieser Wert ignoriert und alle Zeilen ab dem Startpunkt verarbeitet. - - Verarbeitet die Zeilen ab diesem Startpunkt in Paketen der Größe Config.BATCH_SIZE (z. B. 10 Zeilen). - - Für jedes Batch wird ein aggregierter Prompt erstellt, an ChatGPT gesendet und die Antwort - zeilenweise geparst. - - Die Ergebnisse werden in den Spalten S bis Y geschrieben: - S: Wiki-Validierung ("OK" oder "X") - T: Alternativer Wiki-Artikel (URL oder "Kein Wikipedia-Eintrag vorhanden.") - U: Wiki-Erklärung / Begründung - V–Y: Platzhalter (leer) - - Umfangreiche Log-Ausgaben unterstützen die Fehlerdiagnose. - """ - debug_print("Starte Verifizierungsmodus (Modus 51) im Batch-Prozess (Version 1.5.10)...") - - # Ermittlung des Startpunkts: ab Zeile 7 die erste Zeile, in der Spalte AO (Index 41) leer ist. - gc = gspread.authorize(ServiceAccountCredentials.from_json_keyfile_name( - Config.CREDENTIALS_FILE, ["https://www.googleapis.com/auth/spreadsheets"])) - sh = gc.open_by_url(Config.SHEET_URL) - main_sheet = sh.sheet1 - data = main_sheet.get_all_values() - - start_row = None - for i in range(7, len(data) + 1): - row = data[i - 1] - if len(row) < 41 or row[40].strip() == "": - start_row = i - break - if start_row is None: - debug_print("Keine Zeile ohne Zeitstempel in Spalte AO gefunden. Es wird nichts verarbeitet.") - return - debug_print(f"Verarbeitung startet ab Zeile {start_row} (erste Zeile ohne Zeitstempel in Spalte AO).") - - # Abfrage: Wie viele Zeilen sollen insgesamt verarbeitet werden? - try: - total_rows = int(input("Wie viele Zeilen sollen insgesamt bearbeitet werden? ")) - except Exception as e: - debug_print(f"Fehler bei der Eingabe der Zeilenanzahl: {e}. Es werden alle verfügbaren Zeilen verarbeitet.") - total_rows = None - - available_total = len(data) - 1 # ohne Header - # Wenn der Nutzer einen Wert eingibt, der vor dem Startpunkt liegt, wird dieser ignoriert. - if total_rows is not None and total_rows < start_row - 1: - debug_print("Die angegebene Zeilenanzahl liegt vor dem Startpunkt. Es werden alle Zeilen ab dem Startpunkt verarbeitet.") - available_rows = available_total - elif total_rows is not None: - available_rows = total_rows - else: - available_rows = available_total - - if start_row > available_rows + 1: - debug_print("Es gibt keine Zeilen ohne Zeitstempel ab dem Startpunkt. Es wird nichts verarbeitet.") - return - - batch_size = Config.BATCH_SIZE # z. B. 10 - batches = [] - row_numbers = [] - - for i in range(start_row, available_rows + 2): # +1 für Einbeziehung, +1 wegen 1-basierter Index - row = data[i - 1] - entry_text = ( - f"Eintrag {i}:\n" - f"Firmenname: {row[1] if len(row) > 1 else ''}\n" - f"CRM-Beschreibung: {row[7] if len(row) > 7 else ''}\n" - f"Wikipedia-URL: {row[11] if len(row) > 11 and row[11].strip() not in ['', 'k.A.'] else 'k.A.'}\n" - f"Wiki-Absatz: {row[12] if len(row) > 12 else 'k.A.'}\n" - f"Wiki-Kategorien: {row[16] if len(row) > 16 else 'k.A.'}\n" - "-----\n" - ) - batches.append(entry_text) - row_numbers.append(i) - if len(batches) == batch_size: - _process_batch(main_sheet, batches, row_numbers) - batches = [] - row_numbers = [] - if batches: - _process_batch(main_sheet, batches, row_numbers) - debug_print("Verifizierungs-Batch abgeschlossen.") - - -def _process_batch(main_sheet, batches, row_numbers): - """ - Hilfsfunktion: Bearbeitet einen Batch, indem ein aggregierter Prompt erstellt und - die aggregierte Antwort zeilenweise den entsprechenden Zeilennummern zugeordnet wird. - Die Ergebnisse werden in den Spalten S bis Y geschrieben, und anschließend wird - für jede Zeile der aktuelle Zeitstempel (Spalte AO) sowie die Versionsnummer (Spalte AP) eingetragen. - """ - aggregated_prompt = ( - "Du bist ein Experte in der Verifizierung von Wikipedia-Artikeln für Unternehmen. " - "Für jeden der folgenden Einträge prüfe, ob der vorhandene Wikipedia-Artikel (URL, Absatz, Kategorien) plausibel passt. " - "Gib das Ergebnis für jeden Eintrag im Format aus:\n" - "Eintrag : \n" - "Regeln:\n" - "- Bei Übereinstimmung: 'OK'\n" - "- Bei Nichtübereinstimmung: 'Alternativer Wikipedia-Artikel vorgeschlagen: | X | '\n" - "- Falls kein Artikel gefunden wurde: 'Kein Wikipedia-Eintrag vorhanden.'\n\n" - ) - aggregated_prompt += "\n".join(batches) - debug_print(f"Verarbeite Batch für Zeilen {row_numbers[0]} bis {row_numbers[-1]}.") - if tiktoken: - try: - enc = tiktoken.encoding_for_model(Config.TOKEN_MODEL) - debug_print(f"Token-Zahl für aktuellen Batch: {len(enc.encode(aggregated_prompt))}") - except Exception as e: - debug_print(f"Fehler beim Token-Counting: {e}") - try: - with open("api_key.txt", "r") as f: - api_key = f.read().strip() - except Exception as e: - debug_print(f"Fehler beim Lesen des API-Tokens für Verifizierung: {e}") - return - openai.api_key = api_key - try: - response = openai.ChatCompletion.create( - model=Config.TOKEN_MODEL, - messages=[{"role": "user", "content": aggregated_prompt}], - temperature=0.0 - ) - result = response.choices[0].message.content.strip() - debug_print(f"Aggregierte Antwort für Batch {row_numbers[0]}-{row_numbers[-1]}: {result}") - except Exception as e: - debug_print(f"Fehler bei der ChatGPT-Anfrage für Batch {row_numbers[0]}-{row_numbers[-1]}: {e}") - result = "" - answers = result.split("\n") - for current_row in row_numbers: - answer = "k.A." - for line in answers: - if line.strip().startswith(f"Eintrag {current_row}:"): - answer = line.split(":", 1)[1].strip() - break - if answer.upper() == "OK": - wiki_confirm = "OK" - alt_article = "" - wiki_explanation = "" - elif answer.upper() == "KEIN WIKIPEDIA-EINTRAG VORHANDEN.": - wiki_confirm = "" - alt_article = "Kein Wikipedia-Eintrag vorhanden." - wiki_explanation = "" - elif answer.startswith("Alternativer Wikipedia-Artikel vorgeschlagen:"): - parts = answer.split(":", 1)[1].split("|") - alt_article = parts[0].strip() if len(parts) > 0 else "k.A." - wiki_explanation = parts[2].strip() if len(parts) > 2 else "" - wiki_confirm = "X" - else: - wiki_confirm = "" - alt_article = answer - wiki_explanation = answer - try: - main_sheet.update(values=[[wiki_confirm]], range_name=f"S{current_row}") - main_sheet.update(values=[[alt_article]], range_name=f"T{current_row}") - main_sheet.update(values=[[wiki_explanation]], range_name=f"U{current_row}") - main_sheet.update(values=[["", "", "", ""]], range_name=f"V{current_row}:Y{current_row}") - # Neu: Setze Zeitstempel in Spalte AO und Version in Spalte AP - current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - main_sheet.update(values=[[current_timestamp]], range_name=f"AO{current_row}") - main_sheet.update(values=[[Config.VERSION]], range_name=f"AP{current_row}") - debug_print(f"Zeile {current_row} verifiziert: Antwort: {answer} | Zeitstempel: {current_timestamp}, Version: {Config.VERSION}") - except Exception as e: - debug_print(f"Fehler beim Updaten der Zeile {current_row}: {e}") - time.sleep(Config.RETRY_DELAY) -def process_verification_only(): - """ - Überarbeiteter Batch‑Prozess (Version 1.5.10, Modus 51): - - Startet die Verarbeitung ab Zeile 7 und sucht ab dort die erste Zeile, in der Spalte AO (Index 41) leer ist. - - Falls der Nutzer eine Gesamtzeilenanzahl eingibt, die vor dem Startpunkt liegt, - wird dieser Wert ignoriert und alle Zeilen ab dem Startpunkt verarbeitet. - - Verarbeitet die Zeilen ab diesem Startpunkt in Paketen der Größe Config.BATCH_SIZE (z. B. 10 Zeilen). - - Für jedes Batch wird ein aggregierter Prompt erstellt, an ChatGPT gesendet und die Antwort - zeilenweise geparst. - - Die Ergebnisse werden in den Spalten S bis Y geschrieben: - S: Wiki-Validierung ("OK" oder "X") - T: Alternativer Wiki-Artikel (URL oder "Kein Wikipedia-Eintrag vorhanden.") - U: Wiki-Erklärung / Begründung - V–Y: Platzhalter (leer) - - Umfangreiche Log-Ausgaben unterstützen die Fehlerdiagnose. - """ - debug_print("Starte Verifizierungsmodus (Modus 51) im Batch-Prozess (Version 1.5.10)...") - - # Ermittlung des Startpunkts: ab Zeile 7 die erste Zeile, in der Spalte AO (Index 41) leer ist. - gc = gspread.authorize(ServiceAccountCredentials.from_json_keyfile_name( - Config.CREDENTIALS_FILE, ["https://www.googleapis.com/auth/spreadsheets"])) - sh = gc.open_by_url(Config.SHEET_URL) - main_sheet = sh.sheet1 - data = main_sheet.get_all_values() - - start_row = None - for i in range(7, len(data) + 1): - row = data[i - 1] - if len(row) < 41 or row[40].strip() == "": - start_row = i - break - if start_row is None: - debug_print("Keine Zeile ohne Zeitstempel in Spalte AO gefunden. Es wird nichts verarbeitet.") - return - debug_print(f"Verarbeitung startet ab Zeile {start_row} (erste Zeile ohne Zeitstempel in Spalte AO).") - - # Abfrage: Wie viele Zeilen sollen insgesamt verarbeitet werden? - try: - total_rows = int(input("Wie viele Zeilen sollen insgesamt bearbeitet werden? ")) - except Exception as e: - debug_print(f"Fehler bei der Eingabe der Zeilenanzahl: {e}. Es werden alle verfügbaren Zeilen verarbeitet.") - total_rows = None - - available_total = len(data) - 1 # ohne Header - # Wenn der Nutzer einen Wert eingibt, der vor dem Startpunkt liegt, wird dieser ignoriert. - if total_rows is not None and total_rows < start_row - 1: - debug_print("Die angegebene Zeilenanzahl liegt vor dem Startpunkt. Es werden alle Zeilen ab dem Startpunkt verarbeitet.") - available_rows = available_total - elif total_rows is not None: - available_rows = total_rows - else: - available_rows = available_total - - if start_row > available_rows + 1: - debug_print("Es gibt keine Zeilen ohne Zeitstempel ab dem Startpunkt. Es wird nichts verarbeitet.") - return - - batch_size = Config.BATCH_SIZE # z. B. 10 - batches = [] - row_numbers = [] - - for i in range(start_row, available_rows + 2): # +1 für Einbeziehung, +1 wegen 1-basierter Index - row = data[i - 1] - entry_text = ( - f"Eintrag {i}:\n" - f"Firmenname: {row[1] if len(row) > 1 else ''}\n" - f"CRM-Beschreibung: {row[7] if len(row) > 7 else ''}\n" - f"Wikipedia-URL: {row[11] if len(row) > 11 and row[11].strip() not in ['', 'k.A.'] else 'k.A.'}\n" - f"Wiki-Absatz: {row[12] if len(row) > 12 else 'k.A.'}\n" - f"Wiki-Kategorien: {row[16] if len(row) > 16 else 'k.A.'}\n" - "-----\n" - ) - batches.append(entry_text) - row_numbers.append(i) - if len(batches) == batch_size: - _process_batch(main_sheet, batches, row_numbers) - batches = [] - row_numbers = [] - if batches: - _process_batch(main_sheet, batches, row_numbers) - debug_print("Verifizierungs-Batch abgeschlossen.") - - -def _process_batch(main_sheet, batches, row_numbers): - """ - Hilfsfunktion: Bearbeitet einen Batch, indem ein aggregierter Prompt erstellt und - die aggregierte Antwort zeilenweise den entsprechenden Zeilennummern zugeordnet wird. - Die Ergebnisse werden in Spalten S bis Y geschrieben. - """ - aggregated_prompt = ( - "Du bist ein Experte in der Verifizierung von Wikipedia-Artikeln für Unternehmen. " - "Für jeden der folgenden Einträge prüfe, ob der vorhandene Wikipedia-Artikel (URL, Absatz, Kategorien) plausibel passt. " - "Gib das Ergebnis für jeden Eintrag im Format aus:\n" - "Eintrag : \n" - "Regeln:\n" - "- Bei Übereinstimmung: 'OK'\n" - "- Bei Nichtübereinstimmung: 'Alternativer Wikipedia-Artikel vorgeschlagen: | X | '\n" - "- Falls kein Artikel gefunden wurde: 'Kein Wikipedia-Eintrag vorhanden.'\n\n" - ) - aggregated_prompt += "\n".join(batches) - debug_print(f"Verarbeite Batch für Zeilen {row_numbers[0]} bis {row_numbers[-1]}.") - if tiktoken: - try: - enc = tiktoken.encoding_for_model(Config.TOKEN_MODEL) - debug_print(f"Token-Zahl für aktuellen Batch: {len(enc.encode(aggregated_prompt))}") - except Exception as e: - debug_print(f"Fehler beim Token-Counting: {e}") - try: - with open("api_key.txt", "r") as f: - api_key = f.read().strip() - except Exception as e: - debug_print(f"Fehler beim Lesen des API-Tokens für Verifizierung: {e}") - return - openai.api_key = api_key - try: - response = openai.ChatCompletion.create( - model=Config.TOKEN_MODEL, - messages=[{"role": "user", "content": aggregated_prompt}], - temperature=0.0 - ) - result = response.choices[0].message.content.strip() - debug_print(f"Aggregierte Antwort für Batch {row_numbers[0]}-{row_numbers[-1]}: {result}") - except Exception as e: - debug_print(f"Fehler bei der ChatGPT-Anfrage für Batch {row_numbers[0]}-{row_numbers[-1]}: {e}") - result = "" - answers = result.split("\n") - for current_row in row_numbers: - answer = "k.A." - for line in answers: - if line.strip().startswith(f"Eintrag {current_row}:"): - answer = line.split(":", 1)[1].strip() - break - if answer.upper() == "OK": - wiki_confirm = "OK" - alt_article = "" - wiki_explanation = "" - elif answer.upper() == "KEIN WIKIPEDIA-EINTRAG VORHANDEN.": - wiki_confirm = "" - alt_article = "Kein Wikipedia-Eintrag vorhanden." - wiki_explanation = "" - elif answer.startswith("Alternativer Wikipedia-Artikel vorgeschlagen:"): - parts = answer.split(":", 1)[1].split("|") - alt_article = parts[0].strip() if len(parts) > 0 else "k.A." - wiki_explanation = parts[2].strip() if len(parts) > 2 else "" - wiki_confirm = "X" - else: - wiki_confirm = "" - alt_article = answer - wiki_explanation = answer - try: - main_sheet.update(values=[[wiki_confirm]], range_name=f"S{current_row}") - main_sheet.update(values=[[alt_article]], range_name=f"T{current_row}") - main_sheet.update(values=[[wiki_explanation]], range_name=f"U{current_row}") - main_sheet.update(values=[["", "", "", ""]], range_name=f"V{current_row}:Y{current_row}") - debug_print(f"Zeile {current_row} verifiziert: Antwort: {answer}") - except Exception as e: - debug_print(f"Fehler beim Updaten der Zeile {current_row}: {e}") - time.sleep(Config.RETRY_DELAY) - - -# ==================== List Metatitel, Description und Überschriften aus Websiten aus ==================== -def scrape_website_details(url): - """ - Ruft die Website ab und extrahiert folgende Informationen: - - Seitentitel () - - Meta-Description (<meta name="description">) - - Alle Überschriften h1, h2, h3 (als kommaseparierte Listen) - - Die extrahierten Informationen werden in folgender Form kombiniert: - "Title: [Seitentitel] Description: [Meta-Description] H1: [h1-Überschriften] H2: [h2-Überschriften] H3: [h3-Überschriften]" - - Args: - url (str): Die URL der Website. - - Returns: - str: Die formatierte Zusammenfassung oder "k.A." bei Fehlern. - """ - # Falls URL kein Schema besitzt, ergänze "https://" - if not url.lower().startswith("http"): - url = "https://" + url - try: - response = requests.get(url, timeout=10) - soup = BeautifulSoup(response.text, Config.HTML_PARSER) - - # Seitentitel extrahieren - title_tag = soup.find("title") - title = title_tag.get_text().strip() if title_tag and title_tag.get_text() else "k.A." - - # Meta-Description extrahieren - meta_tag = soup.find("meta", attrs={"name": "description"}) - description = meta_tag["content"].strip() if meta_tag and meta_tag.get("content") else "k.A." - - # Überschriften h1, h2, h3 extrahieren und kommasepariert zusammenfassen - headers = {} - for tag in ["h1", "h2", "h3"]: - elements = soup.find_all(tag) - # Extrahiere den Text und filtere leere Ergebnisse - header_texts = [el.get_text().strip() for el in elements if el.get_text().strip()] - headers[tag] = ", ".join(header_texts) if header_texts else "k.A." - - # Kombiniere alle extrahierten Daten in einen String - combined = ( - f"Title: {title} " - f"Description: {description} " - f"H1: {headers['h1']} " - f"H2: {headers['h2']} " - f"H3: {headers['h3']}" - ) - return combined - except Exception as e: - debug_print(f"Fehler beim Auslesen der Website {url}: {e}") - return "k.A." - -# ==================== ALIGNMENT DEMO (Hauptblatt) ==================== -def alignment_demo(sheet): - new_headers = [ - [ # Spaltenname (Zeile 1) - "ReEval Flag", # A - "CRM Name", # B - "CRM Kurzform", # C - "CRM Website", # D - "CRM Ort", # E - "CRM Beschreibung", # F - "CRM Branche", # G - "CRM Beschreibung Branche extern", # H - "CRM Anzahl Techniker", # I - "CRM Umsatz", # J - "CRM Anzahl Mitarbeiter", # K - "CRM Vorschlag Wiki URL", # L - "Wiki URL", # M - "Wiki Absatz", # N - "Wiki Branche", # O - "Wiki Umsatz", # P - "Wiki Mitarbeiter", # Q - "Wiki Kategorien", # R - "Chat Wiki Konsistenzprüfung", # S - "Chat Begründung Wiki Inkonsistenz", # T - "Chat Vorschlag Wiki Artikel", # U - "Begründung bei Abweichung", # V - "Chat Vorschlag Branche", # W - "Chat Konsistenz Branche", # X - "Chat Begründung Abweichung Branche", # Y - "Chat Prüfung FSM Relevanz", # Z - "Chat Begründung für FSM Relevanz", # AA - "Chat Schätzung Anzahl Mitarbeiter", # AB - "Chat Konsistenzprüfung Mitarbeiterzahl", # AC - "Chat Begründung Abweichung Mitarbeiterzahl", # AD - "Chat Einschätzung Anzahl Servicetechniker", # AE - "Chat Begründung Abweichung Anzahl Servicetechniker", # AF - "Chat Schätzung Umsatz", # AG - "Chat Begründung Abweichung Umsatz", # AH - "Linked Serviceleiter gefunden", # AI - "Linked It-Leiter gefunden", # AJ - "Linked Management gefunden", # AK - "Linked Disponent gefunden", # AL - "Contact Search Timestamp", # AM - "Wikipedia Timestamp", # AN - "Timestamp letzte Prüfung", # AO - "Version", # AP - "Tokens", # AQ - "Website Rohtext", # AR - "Website Zusammenfassung" # AS - ], - [ # Quelle der Daten (Zeile 2) - "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", - "CRM", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", - "Wikipediascraper", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", - "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", - "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", - "LinkedIn (via SerpApi)", "LinkedIn (via SerpApi)", "LinkedIn (via SerpApi)", "LinkedIn (via SerpApi)", - "System", "System", "System", "System", "System", - "Web Scraper", # AR - "Chat GPT API" # AS - ], - [ # Feldkategorie (Zeile 3) - "Prozess", "Firmenname", "Firmenname", "Website", "Ort", "Beschreibung (Text)", - "Branche", "Branche", "Anzahl Servicetechniker", "Umsatz", "Anzahl Mitarbeiter", - "Wikipedia Artikel URL", "Wikipedia Artikel", "Beschreibung (Text)", "Branche", - "Umsatz", "Anzahl Mitarbeiter", "Kategorien (Text)", "Verifizierung", - "Begründung bei Abweichung", "Wikipedia Artikel", "Wikipedia Artikel", - "Branche", "Branche", "Branche", "FSM Relevanz", "FSM Relevanz", - "Anzahl Mitarbeiter", "Anzahl Mitarbeiter", "Anzahl Mitarbeiter", - "Anzahl Servicetechniker", "Anzahl Servicetechniker", "Umsatz", "Umsatz", - "Kontakte zur Firma", "Kontakte zur Firma", "Kontakte zur Firma", "Kontakte zur Firma", - "Timestamp", "Timestamp", "Timestamp", - "Version des Skripts die verwendet wurde", "ChatGPT Tokens", - "Website-Content", # AR - "Website Zusammenfassung" # AS - ], - [ # Kurze Beschreibung (Zeile 4) - "Systemspalte, irrelevant für den Prompt. Wird zur manuellen Neuprüfung genutzt.", - "Enthält den Firmennamen; Normalisierung erfolgt bei der Suche.", - "Manuell gepflegte Kurzform, meist die ersten 2 Worte.", - "Website des Unternehmens.", - "Ort des Unternehmens.", - "Kurze Beschreibung des Unternehmens.", - "Aktuelle Branchenzuweisung gemäß Ziel-Branchenschema.", - "Externe Branchenbeschreibung (z.B. von Dealfront).", - "Recherchierte Anzahl Servicetechniker.", - "Umsatz in Mio. € (CRM).", - "Anzahl Mitarbeiter (CRM).", - "Vorgeschlagene Wikipedia URL (Ausgangspunkt).", - "Wikipedia URL (Ergebnis der Suche).", - "Erster Absatz des Wikipedia-Artikels.", - "Wikipedia-Branche – für den Branchenabgleich.", - "Wikipedia-Umsatz – zur Validierung.", - "Wikipedia-Mitarbeiterzahl – zur Validierung.", - "Liste der Wikipedia-Kategorien.", - "\"OK\" oder \"X\" – Ergebnis der Wikipedia-Validierung.", - "Begründung bei Inkonsistenz (Wiki).", - "Chat-Vorschlag Wiki Artikel: Falls kein passender Artikel gefunden, alternativ vorschlagen.", - "Nicht genutzt, evtl. für zukünftige Funktionen.", - "Branchenvorschlag via ChatGPT (alternativer Vorschlag).", - "Vergleich: Übereinstimmung CRM vs. ChatGPT-Branche (OK/X).", - "Begründung bei abweichender Branchenzuordnung.", - "FSM-Relevanz: Bewertung, ob das Unternehmen für FSM geeignet ist (OK/X).", - "Begründung zur FSM-Bewertung.", - "Schätzung Anzahl Mitarbeiter via ChatGPT (nur falls Wiki-Daten fehlen).", - "Vergleich CRM vs. Wiki vs. ChatGPT Mitarbeiterzahl (OK/X).", - "Begründung bei Mitarbeiterabweichung (Prozentdifferenz).", - "Schätzung Servicetechniker via ChatGPT (in Kategorien, z.B. <50, >100, >200, >500).", - "Begründung bei Abweichung der Technikerzahl.", - "Schätzung Umsatz via ChatGPT.", - "Begründung bei Umsatzabweichung.", - "Anzahl Kontakte (Serviceleiter) gefunden.", - "Anzahl Kontakte (IT-Leiter) gefunden.", - "Anzahl Kontakte (Management) gefunden.", - "Anzahl Kontakte (Disponent) gefunden.", - "Timestamp der Kontaktsuche.", - "Timestamp der Wikipedia-Suche.", - "Timestamp der ChatGPT-Bewertung.", - "Ausgabe der Skriptversion, die das Ergebnis erzeugt hat.", - "Token-Zählung (separat pro Modul).", - "Roh extrahierter Text der Firmenwebsite (maximal 1000 Zeichen).", - "Zusammenfassung des Webseiteninhalts, fokussiert auf Tätigkeitsfeld, Produkte & Leistungen." - ], - [ # Aufgabe / Funktion (exakte Vorgabe der Ausgangsversion, unverändert) - "Datenquelle", - "Datenquelle", - "Datenquelle", - "Datenquelle", - "Datenquelle", - "Datenquelle", - "Datenquelle", - "Datenquelle", - "Datenquelle", - "Datenquelle", - "Datenquelle", - "Datenquelle", - "Wird durch Wikipedia Scraper bereitgestellt", - "Wird zunächst nicht verwendet, kann aber zum Vergleich mit der CRM-Beschreibung genutzt werden.", - "Wird u.a. zur finalen Ermittlung der Branche im Ziel-Branchenschema genutzt und mit der CRM-Branche bzw. CRM-Beschreibung Branche Extern verglichen. Stimmen alle drei Einstufungen grob überein, bestärkt dies die ursprüngliche Einstufung. Laufen diese Branchen weit auseinander, soll – sofern der Wikipedia-Artikel verifiziert ist – die Branche von Wikipedia als zuverlässigste Quelle bewertet werden, danach folgen CRM-Beschreibung Branche Extern und CRM-Branche an dritter Stelle.", - "Wird u.a. mit CRM-Umsatz zur Validierung des Unternehmens verglichen bzw. zur Bewertung der Größe / Einschätzung der Technikerzahl bzw. FSM-Relevanz genutzt.", - "Wird u.a. mit CRM-Anzahl Mitarbeiter zur Validierung des Unternehmens verglichen bzw. zur Bewertung der Größe / Einschätzung der Technikerzahl bzw. FSM-Relevanz genutzt.", - "Wenn Website-Daten fehlen, wird in diesem Feld keine zusätzliche Information einbezogen; ansonsten als zusätzlicher Kontext.", - "\"Es soll durch ChatGPT geprüft werden, ob anhand der vorliegenden Daten bestätigt werden kann, dass der Wikipedia-Eintrag das Unternehmen sicher beschreibt. Dabei können alle Daten (Website, Umsatz, Mitarbeiterzahl etc.) berücksichtigt werden. Eine gewisse Toleranz (±30%) ist erlaubt. Insbesondere bei Konzernstrukturen muss großzügig bewertet werden. Abweichungen sollen in der Spalte 'Chat Begründung Wiki Inkonsistenz' begründet werden.\"", - "\"Liegt eine Inkonsistenz zwischen dem gefundenen Wikipedia-Artikel und dem Unternehmen vor, so soll dies kurz begründet werden. Wurde der Artikel als unpassend identifiziert, soll ChatGPT einen alternativen Wikipedia-Artikel vorschlagen und diesen in 'Chat Vorschlag Wiki Artikel' ausgeben.\"", - "\"Sollte durch die Wikipedia-Suche kein Artikel gefunden werden oder als unpassend bewertet werden, soll ChatGPT eigenständig nach einem passenden Artikel recherchieren. Der gefundene Artikel muss vom als unpassend bewerteten Artikel abweichen. Wird kein passender Artikel gefunden, soll 'kein Artikel verfügbar' ausgegeben werden.\"", - "XXX derzeit nicht verwendet, wird vermutlich gelöscht xxx", - "\"ChatGPT soll anhand der vorliegenden Informationen prüfen, welcher Branche des Ziel-Branchenschemas das Unternehmen am ehesten zugeordnet werden kann. Das Ziel-Branchenschema darf nicht verändert werden, sondern die Vorschläge müssen exakt diesem Schema entsprechen.\"", - "Die in Spalte CRM festgelegte Branche soll mit der von ChatGPT ermittelten Branche in 'Chat Vorschlag Branche' verglichen werden.", - "Weicht die von ChatGPT ermittelte Branche von der in CRM vorliegenden ab, so soll ChatGPT die Abweichung kurz begründen.", - "ChatGPT soll anhand der vorliegenden Daten prüfen, ob das Unternehmen für den Einsatz einer Field Service Management Lösung geeignet ist.", - "Die in 'Chat Begründung für FSM Relevanz' angegebene Begründung soll zur Bewertung der FSM-Eignung herangezogen werden.", - "Nur wenn kein Wikipedia-Eintrag vorhanden ist, soll ChatGPT basierend auf öffentlich verfügbaren Informationen die Mitarbeiterzahl schätzen. Falls keine Schätzung möglich ist, wird 'keine Schätzung möglich' ausgegeben.", - "Entspricht die durch ChatGPT ermittelte Mitarbeiterzahl ungefähr den in CRM und Wikipedia ermittelten Werten (±30%), wird 'OK' ausgegeben, andernfalls 'X' und eine Begründung in 'Chat Begründung Abweichung Mitarbeiterzahl'.", - "Weicht die von ChatGPT geschätzte Mitarbeiterzahl signifikant von den CRM- oder Wikipedia-Werten ab, soll dies kurz begründet werden.", - "ChatGPT soll auf Basis öffentlich zugänglicher Informationen eine Schätzung der Anzahl Servicetechniker abgeben (Kategorisierung: 0, <50, >100, >200, >500). Bei Abweichungen der Recherche-Werte soll 'X' ausgegeben werden, ansonsten 'OK'.", - "Weicht die von ChatGPT geschätzte Technikerzahl von den CRM-Werten ab, soll dies begründet werden.", - "Nur wenn kein Wikipedia-Eintrag vorhanden ist, soll ChatGPT den Umsatz anhand der Unternehmenswebsite oder anderer Daten schätzen. Bei fehlender Schätzung soll 'keine Schätzung möglich' ausgegeben werden.", - "ChatGPT soll signifikante Umsatzabweichungen zwischen den Schätzungen von Chat, Wikipedia und CRM begründen. Stimmen die Werte (±30%) überein, wird 'OK' ausgegeben.", - "Über SerpAPI wird zusammen mit der in 'CRM Kurzform' enthaltenen Information nach 'Serviceleiter' gesucht.", - "Über SerpAPI wird zusammen mit 'CRM Kurzform' nach 'Leiter IT' gesucht.", - "Über SerpAPI wird zusammen mit 'CRM Kurzform' nach 'Geschäftsführer' gesucht.", - "Über SerpAPI wird zusammen mit 'CRM Kurzform' erneut nach 'Serviceleiter' gesucht.", - "Wenn die Kontaktsuche gestartet wird, wird der erste Eintrag ohne Zeitstempel gesucht; Zeilen mit vorhandenem Zeitstempel werden übersprungen.", - "Wenn die Wikipedia-Suche gestartet wird, wird der erste Eintrag ohne Zeitstempel gesucht; Zeilen mit vorhandenem Zeitstempel werden übersprungen.", - "Wenn die ChatGPT-Bewertung gestartet wird, wird der erste Eintrag ohne Zeitstempel gesucht; Zeilen mit vorhandenem Zeitstempel werden übersprungen.", - "Wird durch das System befüllt", - "Wird durch tiktoken berechnet" - ] - ] - header_range = "A1:AS5" - sheet.update(values=new_headers, range_name=header_range) - print("Alignment-Demo abgeschlossen: Neues Spaltenschema in Zeilen A1 bis AS5 geschrieben.") - - -# ==================== WIKIPEDIA SCRAPER ==================== -class WikipediaScraper: - def __init__(self): - wikipedia.set_lang(Config.LANG) - def _get_full_domain(self, website): - if not website: - return "" - website = website.lower().strip() - website = re.sub(r'^https?:\/\/', '', website) - website = re.sub(r'^www\.', '', website) - return website.split('/')[0] - def _generate_search_terms(self, company_name, website): - terms = [] - full_domain = self._get_full_domain(website) - if full_domain: - terms.append(full_domain) - normalized_name = normalize_company_name(company_name) - candidate = " ".join(normalized_name.split()[:2]).strip() - if candidate and candidate not in terms: - terms.append(candidate) - if normalized_name and normalized_name not in terms: - terms.append(normalized_name) - debug_print(f"Generierte Suchbegriffe: {terms}") - return terms - def _validate_article(self, page, company_name, website): - full_domain = self._get_full_domain(website) - domain_found = False - if full_domain: - try: - html_raw = requests.get(page.url).text - soup = BeautifulSoup(html_raw, Config.HTML_PARSER) - infobox = soup.find('table', class_=lambda c: c and 'infobox' in c.lower()) - if infobox: - links = infobox.find_all('a', href=True) - for link in links: - href = link.get('href').lower() - if href.startswith('/wiki/datei:'): - continue - if full_domain in href: - debug_print(f"Definitiver Link-Match in Infobox gefunden: {href}") - domain_found = True - break - if not domain_found and hasattr(page, 'externallinks'): - for ext_link in page.externallinks: - if full_domain in ext_link.lower(): - debug_print(f"Definitiver Link-Match in externen Links gefunden: {ext_link}") - domain_found = True - break - except Exception as e: - debug_print(f"Fehler beim Extrahieren von Links: {str(e)}") - normalized_title = normalize_company_name(page.title) - normalized_company = normalize_company_name(company_name) - similarity = SequenceMatcher(None, normalized_title, normalized_company).ratio() - debug_print(f"Ähnlichkeit (normalisiert): {similarity:.2f} ({normalized_title} vs {normalized_company})") - threshold = 0.60 if domain_found else Config.SIMILARITY_THRESHOLD - return similarity >= threshold - def extract_first_paragraph(self, page_url): - try: - response = requests.get(page_url) - soup = BeautifulSoup(response.text, Config.HTML_PARSER) - paragraphs = soup.find_all('p') - for p in paragraphs: - text = clean_text(p.get_text()) - if len(text) > 50: - return text - return "k.A." - except Exception as e: - debug_print(f"Fehler beim Extrahieren des ersten Absatzes: {e}") - return "k.A." - def extract_categories(self, soup): - cat_div = soup.find('div', id="mw-normal-catlinks") - if cat_div: - ul = cat_div.find('ul') - if ul: - cats = [clean_text(li.get_text()) for li in ul.find_all('li')] - return ", ".join(cats) - return "k.A." - def _extract_infobox_value(self, soup, target): - infobox = soup.find('table', class_=lambda c: c and any(kw in c.lower() for kw in ['infobox', 'vcard', 'unternehmen'])) - if not infobox: - return "k.A." - keywords_map = { - 'branche': ['branche', 'industrie', 'tätigkeit', 'geschäftsfeld', 'sektor', 'produkte', 'leistungen', 'aktivitäten', 'wirtschaftszweig'], - 'umsatz': ['umsatz', 'jahresumsatz', 'konzernumsatz', 'gesamtumsatz', 'erlöse', 'umsatzerlöse', 'einnahmen', 'ergebnis', 'jahresergebnis'], - 'mitarbeiter': ['mitarbeiter', 'beschäftigte', 'personal', 'mitarbeiterzahl', 'angestellte', 'belegschaft', 'personalstärke'] - } - keywords = keywords_map.get(target, []) - for row in infobox.find_all('tr'): - header = row.find('th') - if header: - header_text = clean_text(header.get_text()).lower() - if any(kw in header_text for kw in keywords): - value = row.find('td') - if value: - raw_value = clean_text(value.get_text()) - if target == 'branche': - clean_val = re.sub(r'\[.*?\]|\(.*?\)', '', raw_value) - return ' '.join(clean_val.split()).strip() - if target == 'umsatz': - return extract_numeric_value(raw_value, is_umsatz=True) - if target == 'mitarbeiter': - return extract_numeric_value(raw_value, is_umsatz=False) - return "k.A." - def extract_full_infobox(self, soup): - infobox = soup.find('table', class_=lambda c: c and any(kw in c.lower() for kw in ['infobox', 'vcard', 'unternehmen'])) - if not infobox: - return "k.A." - return clean_text(infobox.get_text(separator=' | ')) - def extract_fields_from_infobox_text(self, infobox_text, field_names): - result = {} - tokens = [token.strip() for token in infobox_text.split("|") if token.strip()] - for i, token in enumerate(tokens): - for field in field_names: - if field.lower() in token.lower(): - j = i + 1 - while j < len(tokens) and not tokens[j]: - j += 1 - result[field] = tokens[j] if j < len(tokens) else "k.A." - return result - def extract_company_data(self, page_url): - if not page_url: - return { - 'url': 'k.A.', - 'first_paragraph': 'k.A.', - 'branche': 'k.A.', - 'umsatz': 'k.A.', - 'mitarbeiter': 'k.A.', - 'categories': 'k.A.', - 'full_infobox': 'k.A.' - } - try: - response = requests.get(page_url) - soup = BeautifulSoup(response.text, Config.HTML_PARSER) - full_infobox = self.extract_full_infobox(soup) - extracted_fields = self.extract_fields_from_infobox_text(full_infobox, ['Branche', 'Umsatz', 'Mitarbeiter']) - raw_branche = extracted_fields.get('Branche', self._extract_infobox_value(soup, 'branche')) - raw_umsatz = extracted_fields.get('Umsatz', self._extract_infobox_value(soup, 'umsatz')) - raw_mitarbeiter = extracted_fields.get('Mitarbeiter', self._extract_infobox_value(soup, 'mitarbeiter')) - umsatz_val = extract_numeric_value(raw_umsatz, is_umsatz=True) - mitarbeiter_val = extract_numeric_value(raw_mitarbeiter, is_umsatz=False) - categories_val = self.extract_categories(soup) - first_paragraph = self.extract_first_paragraph(page_url) - return { - 'url': page_url, - 'first_paragraph': first_paragraph, - 'branche': raw_branche, - 'umsatz': umsatz_val, - 'mitarbeiter': mitarbeiter_val, - 'categories': categories_val, - 'full_infobox': full_infobox - } - except Exception as e: - debug_print(f"Extraktionsfehler: {str(e)}") - return { - 'url': 'k.A.', - 'first_paragraph': 'k.A.', - 'branche': 'k.A.', - 'umsatz': 'k.A.', - 'mitarbeiter': 'k.A.', - 'categories': 'k.A.', - 'full_infobox': 'k.A.' - } - @retry_on_failure - def search_company_article(self, company_name, website): - search_terms = self._generate_search_terms(company_name, website) - for term in search_terms: - try: - results = wikipedia.search(term, results=Config.WIKIPEDIA_SEARCH_RESULTS) - debug_print(f"Suchergebnisse für '{term}': {results}") - for title in results: - try: - page = wikipedia.page(title, auto_suggest=False) - if self._validate_article(page, company_name, website): - return page - except (wikipedia.exceptions.DisambiguationError, wikipedia.exceptions.PageError) as e: - debug_print(f"Seitenfehler: {str(e)}") - continue - except Exception as e: - debug_print(f"Suchfehler: {str(e)}") - continue - return None -# Annahme: debug_print und Config sind bereits definiert - -def load_target_branches(csv_filepath="ziel_Branchenschema.csv"): - """ - Liest das Ziel-Branchenschema aus der CSV-Datei ein. - Erwartet, dass in Spalte A der externe (Wikipedia-)Branchenbegriff - und in Spalte B der zugehörige Zielwert (z. B. "maschinenbau") enthalten ist. - Gibt ein Mapping (Dictionary) und eine sortierte Liste der erlaubten Ziel-Branchen zurück. - """ +def load_branch_mapping(file_path=BRANCH_MAPPING_FILE): + """Lädt Mapping extern -> Ziel-Branche aus CSV.""" mapping = {} - allowed_branches = set() try: - with open(csv_filepath, encoding="utf-8") as f: + with open(file_path, mode='r', encoding="utf-8") as f: reader = csv.reader(f) + # Optional: Header überspringen, falls vorhanden + # next(reader, None) for row in reader: if len(row) >= 2: - external = row[0].strip().lower() - target = row[1].strip().lower() - if external and target: - mapping[external] = target - allowed_branches.add(target) - allowed_branches = sorted(list(allowed_branches), key=lambda s: s.lower()) - return mapping, allowed_branches + # Spalte A: Externer Begriff (normalisiert) + # Spalte B: Ziel-Branchenschema + key = normalize_string(row[0].strip()).lower() # Normalisieren für besseres Matching + value = row[1].strip() # Zielwert nicht normalisieren + if key and value: + # Falls der Key schon existiert, überschreibe oder logge einen Konflikt + if key in mapping: + debug_print(f"Warnung: Doppelter Mapping-Key '{key}' in {file_path}. Wert '{mapping[key]}' wird mit '{value}' überschrieben.") + mapping[key] = value + except FileNotFoundError: + debug_print(f"Fehler: Branchen-Mapping-Datei '{file_path}' nicht gefunden.") except Exception as e: - debug_print("Fehler beim Einlesen des Ziel-Branchenschemas: " + str(e)) - return {}, [] + debug_print(f"Fehler beim Laden des Branchen-Mappings aus '{file_path}': {e}") + return mapping -# ==================== NEUE FUNKTION: Angepasste evaluate_branche_chatgpt ==================== -def evaluate_branche_chatgpt(crm_branche, beschreibung, wiki_branche, wiki_kategorien, website_summary): - """ - Ordnet das Unternehmen basierend auf den angegebenen Informationen exakt einer Branche - des in der CSV-Datei hinterlegten Ziel-Branchenschemas zu. +def load_target_schema(csv_filepath=BRANCH_MAPPING_FILE): + """Lädt Mapping, baut Schema-String und Liste erlaubter Ziele.""" + global BRANCH_MAPPING, TARGET_SCHEMA_STRING, ALLOWED_TARGET_BRANCHES + BRANCH_MAPPING = load_branch_mapping(csv_filepath) + + allowed_branches_set = set(BRANCH_MAPPING.values()) + ALLOWED_TARGET_BRANCHES = sorted(list(allowed_branches_set), key=str.lower) - Der System-Prompt enthält nun den erlaubten Branchenbereich, und der von ChatGPT gegebene Vorschlag - wird bereinigt und gegen die Einträge des Ziel-Schemas validiert. - - Falls der Vorschlag nicht validiert werden kann, erfolgt ein Fallback auf den CRM-Wert. - - Args: - crm_branche (str): Branche laut CRM - 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", "consistency" (ok oder X) und "justification". - """ - # Lade Mapping und Liste der erlaubten Ziel-Branchen - mapping, allowed_branches = load_target_branches() - - # Baue den Text für das Ziel-Branchenschema, der im System-Prompt an ChatGPT übergeben wird - schema_lines = ["Ziel-Branchenschema: Folgende Branchenbereiche sind gültig:"] - for branch in allowed_branches: - schema_lines.append(f"- {branch}") - target_schema_info = "\n".join(schema_lines) - - # Erstelle den System-Prompt inklusive der Zielvorgaben. Hier wird das Zielbranchenschema aus der CSV-Datei - # (via load_target_schema) eingebunden. - target_mapping, target_schema_string, allowed_targets = load_target_schema() - - prompt = ( - f"{target_schema_string}\n\n" - f"Ordne das Unternehmen anhand folgender Angaben exakt einer Branche des Ziel-Branchenschemas zu:\n" - f"CRM-Branche: {crm_branche}\n" - f"Beschreibung: {beschreibung}\n" - f"Wikipedia-Branche: {wiki_branche}\n" - f"Wikipedia-Kategorien: {wiki_kategorien}\n" - f"Website-Zusammenfassung: {website_summary}\n\n" - "Antworte im Format:\n" - "Branche: <vorgeschlagene Branche>\n" - "Übereinstimmung: <ok oder X>\n" - "Begründung: <kurze Begründung>" - ) - - try: - with open("api_key.txt", "r") as f: - api_key = f.read().strip() - except Exception as e: - debug_print(f"Fehler beim Lesen des API-Tokens für Brancheneinschätzung: {e}") - return {"branch": crm_branche, "consistency": "X", "justification": "API-Key Fehler"} - openai.api_key = api_key - - try: - response = openai.ChatCompletion.create( - model=Config.TOKEN_MODEL, - messages=[{"role": "user", "content": prompt}], - temperature=0.0 - ) - chat_response = response.choices[0].message.content.strip() - except Exception as e: - debug_print(f"Fehler bei der ChatGPT-Anfrage für Brancheneinschätzung: {e}") - return {"branch": crm_branche, "consistency": "X", "justification": "API-Anfrage Fehler"} - - # Erwarte ein Format: - # Branche: <vorgeschlagene Branche> - # Übereinstimmung: <ok oder X> - # Begründung: <kurze Begründung> - lines = chat_response.split("\n") - suggestion = "" - consistency = "" - explanation = "" - for line in lines: - if line.lower().startswith("branche:"): - suggestion = line.split(":", 1)[1].strip() - elif line.lower().startswith("übereinstimmung:"): - consistency = line.split(":", 1)[1].strip() - elif line.lower().startswith("begründung:"): - explanation = line.split(":", 1)[1].strip() - - # Bereinige den Vorschlag: entferne unnötige Satzzeichen und konvertiere in Kleinbuchstaben - clean_suggestion = re.sub(r'[^\w\s/&-]', '', suggestion).strip().lower() - - # Falls der bereinigte Vorschlag kein Hierarchie-Trennzeichen ">" enthält, übernehme den Präfix aus der CRM-Branche - if ">" not in clean_suggestion and ">" in crm_branche: - prefix = crm_branche.split(">")[0].strip().lower() - clean_suggestion = prefix + " > " + clean_suggestion - - # Prüfe, ob der bereinigte Vorschlag mit einem erlaubten Eintrag (Fuzzy Matching) übereinstimmt - valid = False - for allowed in allowed_branches: - sim = fuzzy_similarity(clean_suggestion, allowed) - if sim > 0.95: # sehr hoher Ähnlichkeitswert (kann angepasst werden) - valid = True - # Setze den Vorschlag exakt auf den Zielwert - clean_suggestion = allowed - break - if not valid: - debug_print(f"Mapping ungültig für Vorschlag: '{clean_suggestion}'. Fallback: CRM-Branche ('{crm_branche}') verwendet.") - return {"branch": crm_branche, "consistency": consistency, "justification": "Fallback: CRM-Wert verwendet aufgrund ungültiger ChatGPT-Zuweisung."} - - return {"branch": clean_suggestion, "consistency": consistency, "justification": explanation} - - - -def evaluate_servicetechnicians_estimate(company_name, company_data): - try: - with open("serpApiKey.txt", "r") as f: - serp_key = f.read().strip() - except Exception as e: - debug_print(f"Fehler beim Lesen des SerpAPI-Schlüssels (Servicetechniker): {e}") - return "k.A." - try: - with open("api_key.txt", "r") as f: - api_key = f.read().strip() - except Exception as e: - debug_print(f"Fehler beim Lesen des API-Tokens (Servicetechniker): {e}") - return "k.A." - openai.api_key = api_key - prompt = ( - f"Bitte schätze auf Basis öffentlich zugänglicher Informationen (insbesondere verifizierte Wikipedia-Daten) " - f"die Anzahl der Servicetechniker für das Unternehmen '{company_name}' ein. " - "Gib die Antwort ausschließlich in einer der folgenden Kategorien aus: '<50 Techniker', '>100 Techniker', '>200 Techniker', '>500 Techniker'." - ) - try: - response = openai.ChatCompletion.create( - model="gpt-3.5-turbo", - messages=[{"role": "system", "content": prompt}], - temperature=0.0 - ) - result = response.choices[0].message.content.strip() - debug_print(f"Schätzung Servicetechniker ChatGPT: '{result}'") - return result - except Exception as e: - debug_print(f"Fehler beim Aufruf der ChatGPT API für Servicetechniker-Schätzung: {e}") - return "k.A." - -def evaluate_servicetechnicians_explanation(company_name, st_estimate, company_data): - try: - with open("api_key.txt", "r") as f: - api_key = f.read().strip() - except Exception as e: - debug_print(f"Fehler beim Lesen des API-Tokens (ST-Erklärung): {e}") - return "k.A." - openai.api_key = api_key - prompt = ( - f"Bitte erkläre, warum du für das Unternehmen '{company_name}' die Anzahl der Servicetechniker als '{st_estimate}' geschätzt hast. " - "Berücksichtige dabei öffentlich zugängliche Informationen (z.B. Branche, Umsatz, Mitarbeiterzahl)." - ) - try: - response = openai.ChatCompletion.create( - model="gpt-3.5-turbo", - messages=[{"role": "system", "content": prompt}], - temperature=0.0 - ) - result = response.choices[0].message.content.strip() - debug_print(f"Servicetechniker-Erklärung ChatGPT: '{result}'") - return result - except Exception as e: - debug_print(f"Fehler beim Aufruf der ChatGPT API für Servicetechniker-Erklärung: {e}") - return "k.A." - -def map_internal_technicians(value): - try: - num = int(value) - except Exception: - return "k.A." - if num < 50: - return "<50 Techniker" - elif num < 100: - return ">100 Techniker" - elif num < 200: - return ">200 Techniker" + if ALLOWED_TARGET_BRANCHES: + schema_lines = ["Ziel-Branchenschema: Folgende Branchenbereiche sind gültig:"] + schema_lines.extend(f"- {branch}" for branch in ALLOWED_TARGET_BRANCHES) + schema_lines.append("Bitte ordne das Unternehmen ausschließlich in einen dieser Bereiche ein.") + TARGET_SCHEMA_STRING = "\n".join(schema_lines) else: - return ">500 Techniker" + TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfügbar (Mapping-Datei leer oder Fehler)." + ALLOWED_TARGET_BRANCHES = [] + debug_print(f"Branchen-Mapping und Schema geladen. {len(BRANCH_MAPPING)} Mappings, {len(ALLOWED_TARGET_BRANCHES)} Zielbranchen.") -def process_batch_token_count(): - debug_print("Batch Token Count Modus (Modus 8) wird ausgeführt.") - time.sleep(Config.RETRY_DELAY) - debug_print("Batch Token Count abgeschlossen.") + +def map_external_branch(external_branch): + """ + Versucht, eine externe Branchenbezeichnung mithilfe des Mappings in das Ziel-Schema zu überführen. + Nutzt Normalisierung und Teilstring-Matching als Fallback. + """ + if not external_branch or not isinstance(external_branch, str) or not BRANCH_MAPPING: + return external_branch # Gib Original zurück, wenn kein Input oder kein Mapping + + norm_external = normalize_string(external_branch).lower() + + # 1. Exaktes Match (nach Normalisierung) + if norm_external in BRANCH_MAPPING: + return BRANCH_MAPPING[norm_external] + + # 2. Teilstring-Match (prüfe, ob ein Mapping-Key im normalisierten Input enthalten ist) + # Sortiere Keys nach Länge (absteigend), um spezifischere Treffer zu bevorzugen + sorted_keys = sorted(BRANCH_MAPPING.keys(), key=len, reverse=True) + for key in sorted_keys: + if key in norm_external: + debug_print(f"Teilstring-Match für Branche: '{key}' in '{norm_external}' -> '{BRANCH_MAPPING[key]}'") + return BRANCH_MAPPING[key] + + # 3. Kein Mapping gefunden + debug_print(f"Kein Mapping für externe Branche '{external_branch}' (normalisiert: '{norm_external}') gefunden.") + return external_branch # Gib Original zurück, wenn kein Mapping passt + + +# ==================== TOKEN COUNT FUNCTION ==================== +@retry_on_failure +def token_count(text): + """Zählt Tokens via tiktoken oder schätzt über Leerzeichen.""" + if not text or not isinstance(text, str): return 0 + if tiktoken: + try: + # Cache encoding object per model + if not hasattr(token_count, 'enc_cache'): + token_count.enc_cache = {} + if Config.TOKEN_MODEL not in token_count.enc_cache: + token_count.enc_cache[Config.TOKEN_MODEL] = tiktoken.encoding_for_model(Config.TOKEN_MODEL) + enc = token_count.enc_cache[Config.TOKEN_MODEL] + return len(enc.encode(text)) + except Exception as e: + debug_print(f"Fehler beim Token-Counting mit tiktoken für Modell '{Config.TOKEN_MODEL}': {e}") + # Fallback zur Schätzung + return len(text.split()) + else: + # Fallback Schätzung + return len(text.split()) # ==================== GOOGLE SHEET HANDLER ==================== class GoogleSheetHandler: def __init__(self): self.sheet = None self.sheet_values = [] + self.headers = [] # Um Header-Zeilen zu speichern self._connect() + self._load_data() + + @retry_on_failure def _connect(self): + """Stellt Verbindung zum Google Sheet her.""" + debug_print("Verbinde mit Google Sheets...") scope = ["https://www.googleapis.com/auth/spreadsheets"] - creds = ServiceAccountCredentials.from_json_keyfile_name(Config.CREDENTIALS_FILE, scope) - self.sheet = gspread.authorize(creds).open_by_url(Config.SHEET_URL).sheet1 + creds = ServiceAccountCredentials.from_json_keyfile_name(CREDENTIALS_FILE, scope) + gc = gspread.authorize(creds) + sh = gc.open_by_url(Config.SHEET_URL) + self.sheet = sh.sheet1 + debug_print("Verbindung zu Google Sheets erfolgreich.") + + @retry_on_failure + def _load_data(self): + """Lädt alle Daten aus dem Sheet.""" + if not self.sheet: + debug_print("Fehler: Keine Sheet-Verbindung zum Laden der Daten.") + return + debug_print("Lade Daten aus Google Sheet...") self.sheet_values = self.sheet.get_all_values() - def get_start_index(self): - filled_n = [row[13] if len(row) > 13 else '' for row in self.sheet_values[1:]] - return next((i + 1 for i, v in enumerate(filled_n, start=1) if not str(v).strip()), len(filled_n) + 1) + if len(self.sheet_values) >= 5: + self.headers = self.sheet_values[:5] # Zeilen 1-5 als Header speichern + else: + self.headers = self.sheet_values[:] # Alle Zeilen als Header, falls weniger als 5 + debug_print(f"Daten geladen: {len(self.sheet_values)} Zeilen insgesamt.") + # Hier könnte die COLUMN_MAP dynamisch erstellt werden, falls gewünscht -# ==================== DATA PROCESSOR ==================== -class DataProcessor: + def get_data(self): + """Gibt die geladenen Daten zurück (ohne Header).""" + # Annahme: Die ersten 5 Zeilen sind Header + header_rows = 5 + if len(self.sheet_values) <= header_rows: + return [] + return self.sheet_values[header_rows:] + + def get_all_data_with_headers(self): + """Gibt alle Daten inklusive Header zurück.""" + return self.sheet_values + + def get_start_row_index(self, timestamp_col_index=COLUMN_MAP["Timestamp letzte Prüfung"]): + """ + Findet den Index der ersten Zeile (0-basiert für Daten nach Header), + in der der Timestamp fehlt. Startet Suche ab Zeile 7 (Index 2 der Datenliste). + """ + header_rows = 5 # Annahme: Zeile 1-5 sind Header + data_rows = self.sheet_values[header_rows:] + + # Startet die Suche ab der 7. Zeile des Sheets, was der 2. Datenzeile entspricht (Index 1) + search_start_index = max(0, 7 - header_rows -1) # Index bezogen auf data_rows + + for i, row in enumerate(data_rows[search_start_index:], start=search_start_index): + if len(row) <= timestamp_col_index or not row[timestamp_col_index].strip(): + actual_sheet_row = i + header_rows + 1 # 1-basierte Zeilennummer im Sheet + debug_print(f"Erste Zeile ohne Zeitstempel in Spalte {timestamp_col_index+1} gefunden: Zeile {actual_sheet_row} (Daten-Index {i})") + return i # Gibt den 0-basierten Index *innerhalb der Datenliste* zurück + + # Wenn alle Zeilen ab Zeile 7 einen Zeitstempel haben + last_index = len(data_rows) + debug_print(f"Alle Zeilen ab Zeile 7 haben einen Zeitstempel. Nächster Index wäre {last_index}.") + return last_index # Gibt den Index nach der letzten Datenzeile zurück + + @retry_on_failure + def batch_update_cells(self, update_data): + """ + Führt ein Batch-Update im Google Sheet durch. + Args: + update_data (list): Eine Liste von Dictionaries, jedes mit 'range' und 'values'. + z.B. [{'range': 'A1', 'values': [['Wert']]}, ...] + """ + if not self.sheet: + debug_print("Fehler: Keine Sheet-Verbindung für Batch-Update.") + return False + if not update_data: + debug_print("Keine Daten für Batch-Update vorhanden.") + return True # Kein Fehler, aber nichts zu tun + try: + self.sheet.batch_update(update_data) + debug_print(f"Batch-Update erfolgreich ({len(update_data)} Zellen/Bereiche aktualisiert).") + return True + except gspread.exceptions.APIError as e: + debug_print(f"Google API Fehler beim Batch-Update: {e}") + # Hier könnte spezifische Fehlerbehandlung erfolgen (z.B. RateLimit) + raise # Fehler weitergeben, damit retry greifen kann + except Exception as e: + debug_print(f"Allgemeiner Fehler beim Batch-Update: {e}") + raise # Fehler weitergeben + + +# ==================== WIKIPEDIA SCRAPER ==================== +class WikipediaScraper: def __init__(self): - self.sheet_handler = GoogleSheetHandler() - self.wiki_scraper = WikipediaScraper() - def process_rows(self, num_rows=None): - global MODE - if MODE == "2": - print("Re-Evaluierungsmodus: Verarbeitung aller Zeilen mit 'x' in Spalte A.") - for i, row in enumerate(self.sheet_handler.sheet_values[1:], start=2): - if row[0].strip().lower() == "x": - self._process_single_row(i, row) - elif MODE == "3": - print("Alignment-Demo-Modus: Schreibe neue Spaltenüberschriften in Hauptblatt und Contacts.") - alignment_demo_full() - elif MODE == "4": - for i, row in enumerate(self.sheet_handler.sheet_values[1:], start=2): - if len(row) <= 39 or row[39].strip() == "": - self._process_single_row(i, row, process_wiki=True, process_chatgpt=False) - elif MODE == "5": - for i, row in enumerate(self.sheet_handler.sheet_values[1:], start=2): - if len(row) <= 40 or row[40].strip() == "": - self._process_single_row(i, row, process_wiki=False, process_chatgpt=True) - elif MODE == "51": - process_verification_only() - elif MODE == "8": - process_batch_token_count() - else: - start_index = self.sheet_handler.get_start_index() - print(f"Starte bei Zeile {start_index+1}") - rows_processed = 0 - for i, row in enumerate(self.sheet_handler.sheet_values[1:], start=2): - if i < start_index: - continue - if num_rows is not None and rows_processed >= num_rows: - break - self._process_single_row(i, row) - rows_processed += 1 -def _process_single_row(self, row_num, row_data, process_wiki=True, process_chatgpt=True): - # Hole den Firmennamen aus Spalte B - company_name = row_data[1] if len(row_data) > 1 else "" - - # Hole die CRM-Website (Spalte D). Wenn diese leer ist, führe den SERP-API Lookup durch. - website_url = row_data[3] if len(row_data) > 3 else "" - if website_url.strip() == "" or website_url.strip().lower() == "k.a.": - new_website = serp_website_lookup(company_name) - if new_website != "k.A.": - website_url = new_website + try: + wikipedia.set_lang(Config.LANG) + except Exception as e: + debug_print(f"Fehler beim Setzen der Wikipedia-Sprache: {e}") + + def _get_full_domain(self, website): + """Extrahiert Domain (ohne www, ohne Pfad) aus URL.""" + if not website or not isinstance(website, str): return "" + # Nutze die normalisierte URL + normalized_url = simple_normalize_url(website) + if normalized_url == "k.A.": return "" + # Entferne 'www.' falls vorhanden + if normalized_url.startswith("www."): + return normalized_url[4:] + return normalized_url + + def _generate_search_terms(self, company_name, website): + """Generiert Suchbegriffe für Wikipedia.""" + terms = set() # Verwende Set, um Duplikate zu vermeiden + + # 1. Domain (ohne www) + full_domain = self._get_full_domain(website) + if full_domain: + terms.add(full_domain.split('.')[0]) # Nur der Domain-Name selbst + + # 2. Normalisierter Firmenname (verschiedene Längen) + normalized_name = normalize_company_name(company_name) + if normalized_name: + name_parts = normalized_name.split() + if len(name_parts) > 0: + terms.add(name_parts[0]) # Erstes Wort + if len(name_parts) > 1: + terms.add(" ".join(name_parts[:2])) # Erste zwei Worte + terms.add(normalized_name) # Ganzer normalisierter Name + + # 3. Original Firmenname (falls abweichend und nicht zu lang) + original_name_cleaned = clean_text(company_name).lower() + if original_name_cleaned != normalized_name and len(original_name_cleaned) < 50: + terms.add(original_name_cleaned) + + # Filter leere Strings und konvertiere zu Liste + final_terms = [term for term in terms if term] + debug_print(f"Generierte Wikipedia-Suchbegriffe für '{company_name}': {final_terms}") + return final_terms + + @retry_on_failure + def _fetch_page_content(self, page_title): + """Lädt eine Wikipedia-Seite sicher.""" + try: + # Nutze page() mit auto_suggest=False und preload=True für Effizienz + page = wikipedia.page(page_title, auto_suggest=False, preload=True) + return page + except wikipedia.exceptions.PageError: + debug_print(f"Wikipedia PageError: Seite '{page_title}' nicht gefunden.") + return None + except wikipedia.exceptions.DisambiguationError as e: + debug_print(f"Wikipedia DisambiguationError für '{page_title}': {e.options[:5]}") + # Optional: Versuche, die erste Option automatisch zu wählen? + # try: + # return wikipedia.page(e.options[0], auto_suggest=False, preload=True) + # except Exception as inner_e: + # debug_print(f"Fehler beim Laden der ersten Disambiguation-Option: {inner_e}") + # return None + return None # Vorerst keine automatische Auswahl + except Exception as e: + debug_print(f"Allgemeiner Fehler beim Laden der Wikipedia-Seite '{page_title}': {e}") + return None + + @retry_on_failure + def _fetch_page_html(self, page_url): + """ Lädt HTML einer Seite für manuelles Parsing. """ + try: + response = requests.get(page_url, timeout=10) + response.raise_for_status() + return response.text + except requests.exceptions.RequestException as e: + debug_print(f"Fehler beim Abrufen von HTML von {page_url}: {e}") + return None + + + def _validate_article(self, page, company_name, website): + """Prüft Ähnlichkeit Titel vs. Name und ob Domain im Artikel vorkommt.""" + if not page: return False + + page_title = page.title + normalized_title = normalize_company_name(page_title) + normalized_company = normalize_company_name(company_name) + + # 1. Ähnlichkeitsprüfung der Namen + name_similarity = fuzzy_similarity(normalized_title, normalized_company) + debug_print(f"Namensähnlichkeit für '{page_title}': {name_similarity:.2f} ('{normalized_title}' vs '{normalized_company}')") + + # 2. Domain-Prüfung + full_domain = self._get_full_domain(website) + domain_found = False + if full_domain: try: - self.sheet_handler.sheet.update(values=[[website_url]], range_name=f"D{row_num}") - debug_print(f"Zeile {row_num}: CRM-Website war leer – neue Website gefunden und in Spalte D eingetragen: {website_url}") + # Prüfe externe Links zuerst (effizienter) + if hasattr(page, 'externallinks'): + for ext_link in page.externallinks: + if full_domain in ext_link.lower(): + debug_print(f"Domain '{full_domain}' in externem Link gefunden: {ext_link}") + domain_found = True + break + + # Wenn nicht gefunden, prüfe Infobox (aufwändiger, erfordert HTML-Parsing) + if not domain_found: + html_content = self._fetch_page_html(page.url) + if html_content: + soup = BeautifulSoup(html_content, Config.HTML_PARSER) + infobox = soup.find('table', class_=lambda c: c and 'infobox' in c.lower()) + if infobox: + links = infobox.find_all('a', href=True) + for link in links: + href = link.get('href', '').lower() + # Suche nach der Domain in externen Links innerhalb der Infobox + if full_domain in href and ('http://' in href or 'https://' in href): + debug_print(f"Domain '{full_domain}' in Infobox-Link gefunden: {href}") + domain_found = True + break except Exception as e: - debug_print(f"Zeile {row_num}: Fehler beim Updaten der CRM-Website in Spalte D: {e}") - else: - debug_print(f"Zeile {row_num}: Keine Website gefunden für {company_name}.") + debug_print(f"Fehler bei der Domain-Validierung für '{page_title}': {e}") + + # 3. Entscheidung + # Hohe Ähnlichkeit ODER moderate Ähnlichkeit UND Domain gefunden + threshold = Config.SIMILARITY_THRESHOLD + if name_similarity >= threshold + 0.1: # Bei sehr hoher Ähnlichkeit + debug_print(f"Validierung OK (Hohe Namensähnlichkeit): {page_title}") + return True + if name_similarity >= threshold - 0.1 and domain_found: # Bei moderater Ähnlichkeit, wenn Domain passt + debug_print(f"Validierung OK (Moderate Ähnlichkeit + Domain gefunden): {page_title}") + return True - # Unabhängig vom process_wiki-Flag: Führe Website-Scraping durch, sofern eine Website vorliegt. - website_raw = "k.A." - website_summary = "k.A." - if website_url.strip() != "" and website_url.strip().lower() != "k.a.": - website_raw = get_website_raw(website_url) - website_summary = summarize_website_content(website_raw) - debug_print(f"Zeile {row_num}: Website-Daten gescrapt. Rohtext (Länge {len(website_raw)}): {website_raw[:100]}..., Zusammenfassung: {website_summary}") - else: - debug_print(f"Zeile {row_num}: Kein gültiger Website-URL vorhanden, Website-Scraping wird übersprungen.") + debug_print(f"Validierung fehlgeschlagen für '{page_title}' (Ähnlichkeit: {name_similarity:.2f}, Domain gefunden: {domain_found})") + return False - # Erstelle einen Dict mit allen Werten, die in dieser Zeile aktualisiert werden sollen. - # Dadurch können wir alle Updates in einem einzigen Aufruf zusammenfassen. - updates = {} - # Spalte AR: Website Rohtext - updates[f"AR{row_num}"] = website_raw - # Spalte AS: Website Zusammenfassung - updates[f"AS{row_num}"] = website_summary + def extract_first_paragraph(self, page_content): + """Extrahiert den ersten sinnvollen Absatz aus dem Seiteninhalt.""" + if not page_content: return "k.A." + # Nutze page.summary, da dies oft der erste Absatz ist + summary = clean_text(page_content) + if len(summary) > 50: + # Begrenze Länge, um nicht zu viel Text zu haben + return summary[:1000] # Max 1000 Zeichen + return "k.A." - # Weiterer Verarbeitungsteil: Wikipedia-Verarbeitung (falls process_wiki True) - wiki_update_range = f"L{row_num}:R{row_num}" - dt_wiki_range = f"AN{row_num}" - company_data = {} - if process_wiki: - if len(row_data) <= 39 or row_data[39].strip() == "": - if len(row_data) > 11 and row_data[11].strip() not in ["", "k.A."]: - wiki_url = row_data[11].strip() - try: - company_data = self.wiki_scraper.extract_company_data(wiki_url) - except Exception as e: - debug_print(f"Zeile {row_num}: Fehler beim Laden des vorgeschlagenen Wikipedia-Artikels: {e}") - article = self.wiki_scraper.search_company_article(company_name, website_url) - company_data = self.wiki_scraper.extract_company_data(article.url) if article else { - 'url': 'k.A.', 'first_paragraph': 'k.A.', 'branche': 'k.A.', - 'umsatz': 'k.A.', 'mitarbeiter': 'k.A.', 'categories': 'k.A.', - 'full_infobox': 'k.A.' - } - else: - article = self.wiki_scraper.search_company_article(company_name, website_url) - company_data = self.wiki_scraper.extract_company_data(article.url) if article else { - 'url': 'k.A.', 'first_paragraph': 'k.A.', 'branche': 'k.A.', - 'umsatz': 'k.A.', 'mitarbeiter': 'k.A.', 'categories': 'k.A.', - 'full_infobox': 'k.A.' - } - updates.update({ - f"L{row_num}": row_data[11] if len(row_data) > 11 and row_data[11].strip() not in ["", "k.A."] else "k.A.", - f"M{row_num}": company_data.get('url', 'k.A.'), - f"N{row_num}": company_data.get('first_paragraph', 'k.A.'), - f"O{row_num}": company_data.get('branche', 'k.A.'), - f"P{row_num}": company_data.get('umsatz', 'k.A.'), - f"Q{row_num}": company_data.get('mitarbeiter', 'k.A.'), - f"R{row_num}": company_data.get('categories', 'k.A.') - }) - updates[dt_wiki_range] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - else: - debug_print(f"Zeile {row_num}: Wikipedia-Timestamp bereits gesetzt – überspringe Wiki-Auswertung.") - # ChatGPT-Verarbeitung (Umsatz, FSM, Mitarbeiter, Branchenevaluierung) - dt_chat_range = f"AO{row_num}" - ver_range = f"AP{row_num}" - if process_chatgpt: - if len(row_data) <= 40 or row_data[40].strip() == "": - crm_umsatz = row_data[8] if len(row_data) > 8 else "k.A." - abgleich_result = compare_umsatz_values(crm_umsatz, company_data.get('umsatz', 'k.A.')) - updates[f"AG{row_num}"] = abgleich_result + def _extract_infobox_data(self, page_url): + """Extrahiert Branche, Umsatz, Mitarbeiter aus der Infobox (via HTML).""" + data = {'branche': 'k.A.', 'umsatz': 'k.A.', 'mitarbeiter': 'k.A.'} + html_content = self._fetch_page_html(page_url) + if not html_content: return data - crm_data = ";".join(row_data[1:10]) - wiki_data_str = ";".join(row_data[11:18]) - valid_result = process_wiki_verification(crm_data, wiki_data_str) - updates[f"R{row_num}"] = valid_result + try: + soup = BeautifulSoup(html_content, Config.HTML_PARSER) + # Finde Infobox (flexiblere Suche nach Klassen) + infobox = soup.find('table', class_=lambda c: c and any(kw in c.lower() for kw in ['infobox', 'vcard', 'unternehmen'])) + if not infobox: return data - fsm_result = evaluate_fsm_suitability(company_name, company_data) - updates[f"Y{row_num}"] = fsm_result["suitability"] - updates[f"Z{row_num}"] = fsm_result["justification"] + # Definiere Keywords für jede Information + keywords_map = { + 'branche': ['branche', 'industrie', 'tätigkeit', 'geschäftsfeld', 'sektor', 'produkte', 'leistungen', 'wirtschaftszweig'], + 'umsatz': ['umsatz', 'jahresumsatz', 'erlöse', 'umsatzerlöse', 'einnahmen', 'ergebnis'], + 'mitarbeiter': ['mitarbeiter', 'beschäftigte', 'personal', 'mitarbeiterzahl', 'angestellte', 'belegschaft'] + } - st_estimate = evaluate_servicetechnicians_estimate(company_name, company_data) - updates[f"AD{row_num}"] = st_estimate + rows = infobox.find_all('tr') + for row in rows: + header = row.find('th') + value_cell = row.find('td') + if header and value_cell: + header_text = clean_text(header.get_text()).lower() + raw_value_text = value_cell.get_text(separator=' ', strip=True) # Text aus der Zelle holen - internal_value = row_data[7] if len(row_data) > 7 else "k.A." - internal_category = map_internal_technicians(internal_value) if internal_value != "k.A." else "k.A." - if internal_category != "k.A." and st_estimate != internal_category: - explanation = evaluate_servicetechnicians_explanation(company_name, st_estimate, company_data) - discrepancy = explanation - else: - discrepancy = "ok" - updates[f"AF{row_num}"] = discrepancy + # Suche nach Keywords in der Kopfzeile + for key, keywords in keywords_map.items(): + if any(kw in header_text for kw in keywords): + # Wenn ein Keyword passt, verarbeite den Wert + if key == 'branche': + # Für Branche: Bereinige Referenzen und Klammern, bevor clean_text + cleaned_branch = re.sub(r'\[.*?\]|\(.*?\)', '', raw_value_text) + data['branche'] = clean_text(cleaned_branch) + elif key == 'umsatz': + data['umsatz'] = extract_numeric_value(raw_value_text, is_umsatz=True) + elif key == 'mitarbeiter': + data['mitarbeiter'] = extract_numeric_value(raw_value_text, is_umsatz=False) + # Optional: break, wenn ein Wert für diese Zeile gefunden wurde? + # break # Verhindert, dass z.B. "Umsatz" auch als "Ergebnis" interpretiert wird, falls beide Keywords passen + + # Fallback: Manchmal steht die Branche ohne explizites th da + if data['branche'] == 'k.A.': + possible_branches = infobox.select('tr > td[colspan="2"]') # Suche nach Zellen über 2 Spalten + for pb in possible_branches: + pb_text = clean_text(pb.get_text()) + # Prüfe, ob Text nach Branche aussieht (keine Zahlen, nicht zu lang) + if pb_text and not any(char.isdigit() for char in pb_text) and len(pb_text) < 100: + is_likely_branch = True + for kw_list in keywords_map.values(): # Nicht mit anderen Keywords verwechseln + if any(kw in pb_text.lower() for kw in kw_list): + is_likely_branch = False + break + if is_likely_branch: + data['branche'] = pb_text + break + + except Exception as e: + debug_print(f"Fehler beim Parsen der Infobox von {page_url}: {e}") - crm_employee = row_data[10] if len(row_data) > 10 else "k.A." - wiki_employee = company_data.get('mitarbeiter', 'k.A.') - emp_estimate = process_employee_estimation(company_name, company_data.get('first_paragraph', 'k.A.'), crm_employee) - emp_consistency = process_employee_consistency(crm_employee, wiki_employee, emp_estimate) - updates[f"AB{row_num}"] = emp_estimate - updates[f"AC{row_num}"] = emp_consistency + return data - revenue_result = evaluate_umsatz_chatgpt(company_name, company_data.get('umsatz', 'k.A.')) - updates[f"AG{row_num}"] = revenue_result - total_tokens = f"Wiki: {token_count(str(company_data.get('first_paragraph', '')))}, Chat: {token_count(crm_data + wiki_data_str)}, Emp: {token_count(str(emp_estimate))}" - updates[f"AQ{row_num}"] = total_tokens - updates[dt_chat_range] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - else: - debug_print(f"Zeile {row_num}: ChatGPT-Timestamp bereits gesetzt – überspringe ChatGPT-Auswertung.") + def extract_categories(self, page_url): + """Extrahiert Kategorien (via HTML).""" + html_content = self._fetch_page_html(page_url) + if not html_content: return "k.A." + try: + soup = BeautifulSoup(html_content, Config.HTML_PARSER) + cat_div = soup.find('div', id="mw-normal-catlinks") + if cat_div: + ul = cat_div.find('ul') + if ul: + cats = [clean_text(li.get_text()) for li in ul.find_all('li')] + # Filtere leere Kategorien und Standardkategorien + cats = [cat for cat in cats if cat and cat != "Kategorien:" and "Wikipedia:" not in cat] + return ", ".join(cats) if cats else "k.A." + except Exception as e: + debug_print(f"Fehler beim Extrahieren der Kategorien von {page_url}: {e}") + return "k.A." - # Abschließende Updates: Timestamp für letzte Prüfung und Version - current_dt = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - updates[ver_range] = current_dt - updates["AP" + str(row_num)] = Config.VERSION + def extract_company_data(self, page_url): + """Extrahiert alle relevanten Daten von einer Wikipedia-Seite.""" + default_data = { + 'url': page_url if page_url else 'k.A.', + 'first_paragraph': 'k.A.', 'branche': 'k.A.', 'umsatz': 'k.A.', + 'mitarbeiter': 'k.A.', 'categories': 'k.A.' + } + if not page_url or page_url == 'k.A.': + return default_data - # Führe ein Batch-Update aller gesammelten Werte für diese Zeile durch + # Lade Seiteninhalt über die wikipedia library (für summary) + page = self._fetch_page_content(page_url.split('/')[-1]) # Nutze Titel aus URL + if not page: + # Wenn Seite nicht geladen werden kann, HTML trotzdem versuchen zu parsen + debug_print(f"Konnte Seite '{page_url}' nicht über Wikipedia-Lib laden, versuche HTML-Parsing.") + + # Extrahiere Daten, die HTML benötigen + infobox_data = self._extract_infobox_data(page_url) + categories_val = self.extract_categories(page_url) + + # Extrahiere Absatz (nutze page.summary wenn verfügbar, sonst leer) + first_paragraph = self.extract_first_paragraph(page.summary) if page else "k.A." + + # Kombiniere Ergebnisse + company_data = { + 'url': page_url, + 'first_paragraph': first_paragraph, + 'branche': infobox_data['branche'], + 'umsatz': infobox_data['umsatz'], + 'mitarbeiter': infobox_data['mitarbeiter'], + 'categories': categories_val + } + # debug_print(f"Extrahierte Wiki-Daten für {page_url}: {company_data}") + return company_data + + # retry_on_failure ist hier schon drauf + def search_company_article(self, company_name, website): + """Sucht nach einem passenden Wikipedia-Artikel.""" + search_terms = self._generate_search_terms(company_name, website) + if not search_terms: + debug_print("Keine Suchbegriffe generiert, Wikipedia-Suche übersprungen.") + return None + + for term in search_terms: + try: + # wikipedia.search gibt Titel zurück + results = wikipedia.search(term, results=Config.WIKIPEDIA_SEARCH_RESULTS) + debug_print(f"Wikipedia-Suchergebnisse für '{term}': {results}") + for title in results: + page = self._fetch_page_content(title) + if page and self._validate_article(page, company_name, website): + debug_print(f"Passenden Wikipedia-Artikel gefunden: {page.url}") + return page # Gib das Page-Objekt zurück + except Exception as e: + # Fehler bei der Suche selbst (Netzwerk etc.) + debug_print(f"Fehler während der Wikipedia-Suche für '{term}': {e}") + # Hier nicht abbrechen, sondern nächsten Suchbegriff versuchen + continue # Zum nächsten Suchbegriff + + debug_print(f"Kein passender Wikipedia-Artikel für '{company_name}' gefunden.") + return None + +# ==================== WEBSITE SCRAPING ==================== + +@retry_on_failure +def get_website_raw(url, max_length=1000, verify_cert=False): + """Holt Textinhalt von einer Website.""" + if not url or not isinstance(url, str) or url.strip().lower() == 'k.a.': + return "k.A." + + # Normalisiere URL und füge https hinzu wenn nötig + if not url.lower().startswith("http"): + url = "https://" + url + + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36" + } + try: - batch_updates = [] - for cell, value in updates.items(): - batch_updates.append({ - "range": cell, - "values": [[value]] - }) - # Verwende die batch_update-Methode von gspread - self.sheet_handler.sheet.batch_update(batch_updates) - debug_print(f"Zeile {row_num}: Batch-Update erfolgreich durchgeführt. Geschriebene Werte: {updates}") - except Exception as e: - debug_print(f"Zeile {row_num}: Fehler beim Batch-Update: {e}") + # Deaktiviere Zertifikatsprüfung explizit, falls verify_cert=False + response = requests.get(url, timeout=10, headers=headers, verify=verify_cert) + response.raise_for_status() # Fehler bei Statuscodes >= 400 - debug_print(f"Zeile {row_num} abgeschlossen. URL: {company_data.get('url', 'k.A.')}, " - f"Branche: {company_data.get('branche', 'k.A.')}, Umsatz-Abgleich: {abgleich_result}, " - f"Validierung: {valid_result}, FSM: {fsm_result['suitability']}, " - f"Servicetechniker-Schätzung: {st_estimate}") + # Encoding prüfen und ggf. korrigieren + response.encoding = response.apparent_encoding + + soup = BeautifulSoup(response.text, Config.HTML_PARSER) + + # Entferne Skript- und Style-Tags + for script_or_style in soup(["script", "style"]): + script_or_style.decompose() + + # Finde body oder main content + body = soup.find('body') + content_area = soup.find('main') or body # Bevorzuge <main>, sonst <body> + + if content_area: + # Extrahiere Text, trenne mit Leerzeichen, entferne überflüssigen Whitespace + text = content_area.get_text(separator=' ', strip=True) + text = re.sub(r'\s+', ' ', text) # Normalisiere Whitespace + result = text[:max_length] + debug_print(f"Website {url} erfolgreich gescrapt. Extrahierter Text (Länge {len(result)}): {result[:100]}...") + return result + else: + debug_print(f"Kein <body> oder <main> Tag gefunden in {url}") + return "k.A." + except requests.exceptions.SSLError as e: + debug_print(f"SSL-Fehler beim Abrufen der Website {url}: {e}. Versuche ohne Zertifikatsprüfung...") + # Erneuter Versuch ohne Verifizierung + if verify_cert: # Nur wenn der erste Versuch mit Verifizierung war + return get_website_raw(url, max_length, verify_cert=False) + else: + return "k.A." # Wenn auch ohne Verifizierung Fehler auftritt + except requests.exceptions.RequestException as e: + debug_print(f"Netzwerk-/HTTP-Fehler beim Abrufen der Website {url}: {e}") + return "k.A." + except Exception as e: + debug_print(f"Allgemeiner Fehler beim Scraping von {url}: {e}") + return "k.A." + +@retry_on_failure +def scrape_website_details(url): + """Extrahiert Title, Description, H1-H3 von einer Website.""" + if not url or not isinstance(url, str) or url.strip().lower() == 'k.a.': + return "k.A." + + if not url.lower().startswith("http"): + url = "https://" + url + + headers = {"User-Agent": "Mozilla/5.0"} + try: + response = requests.get(url, timeout=10, headers=headers, verify=False) # Oft nötig bei vielen Seiten + response.raise_for_status() + response.encoding = response.apparent_encoding + soup = BeautifulSoup(response.text, Config.HTML_PARSER) + + # Title + title_tag = soup.find("title") + title = clean_text(title_tag.get_text()) if title_tag else "k.A." + + # Description + meta_tag = soup.find("meta", attrs={"name": lambda x: x and x.lower() == "description"}) + description = clean_text(meta_tag["content"]) if meta_tag and meta_tag.get("content") else "k.A." + + # Headers H1-H3 + headers_data = {} + for tag in ["h1", "h2", "h3"]: + elements = soup.find_all(tag) + header_texts = [clean_text(el.get_text()) for el in elements] + header_texts = [h for h in header_texts if h != "k.A." and len(h) > 2] # Filtern + headers_data[tag] = ", ".join(header_texts[:5]) if header_texts else "k.A." # Max 5 pro Typ + + combined = ( + f"Title: {title} | Description: {description} | " + f"H1: {headers_data['h1']} | H2: {headers_data['h2']} | H3: {headers_data['h3']}" + ) + # Kürze ggf. das Gesamtergebnis + return combined[:1500] # Limit Gesamtstring + + except requests.exceptions.RequestException as e: + debug_print(f"Netzwerk-/HTTP-Fehler beim Detail-Scraping von {url}: {e}") + return "k.A." + except Exception as e: + debug_print(f"Allgemeiner Fehler beim Detail-Scraping von {url}: {e}") + return "k.A." + + +# ==================== OPENAI / CHATGPT FUNCTIONS ==================== + +@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'): + debug_print("Fehler: OpenAI API Key nicht konfiguriert.") + return None + if not prompt: + debug_print("Fehler: Leerer Prompt für OpenAI.") + return None + + current_model = model if model else Config.TOKEN_MODEL + + try: + # Token zählen vor dem Senden (optional, aber gut für Debugging) + # prompt_tokens = token_count(prompt) + # debug_print(f"Sende Prompt an OpenAI ({current_model}, {prompt_tokens} Tokens)...") + + response = openai.ChatCompletion.create( + model=current_model, + messages=[{"role": "user", "content": prompt}], + temperature=temperature + ) + result = response.choices[0].message.content.strip() + + # Token zählen für die Antwort + # completion_tokens = token_count(result) + # total_tokens = response.usage.total_tokens + # debug_print(f"OpenAI Antwort erhalten ({completion_tokens} Completion Tokens, {total_tokens} Gesamt).") + + return result + except openai.error.InvalidRequestError as e: + debug_print(f"OpenAI Invalid Request Error: {e}") + # Hier könnte man prüfen, ob es am Token Limit liegt + if "maximum context length" in str(e): + debug_print("Fehler scheint Token Limit zu sein. Prompt evtl. zu lang.") + # TODO: Strategie für zu lange Prompts (kürzen, splitten?) + return None + except openai.error.OpenAIError as e: # Fängt RateLimitError, APIError etc. ab + debug_print(f"OpenAI API Fehler: {e}") + raise # Fehler weitergeben, damit retry_on_failure greifen kann + except Exception as e: + debug_print(f"Allgemeiner Fehler bei OpenAI-Aufruf: {e}") + raise # Fehler weitergeben + +def summarize_website_content(raw_text): + """Erstellt Zusammenfassung von Website-Rohtext via OpenAI.""" + if not raw_text or raw_text == "k.A." or raw_text.strip() == "": + return "k.A." + + # Kürze den Rohtext, falls er sehr lang ist, um Token zu sparen/Limits zu vermeiden + max_raw_length = 3000 # Zeichenlimit für den Input der Zusammenfassung + if len(raw_text) > max_raw_length: + debug_print(f"Kürze Rohtext für Zusammenfassung von {len(raw_text)} auf {max_raw_length} Zeichen.") + raw_text = 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):" + ) + summary = call_openai_chat(prompt, temperature=0.2) + return summary if summary else "k.A." + + +def evaluate_branche_chatgpt(crm_branche, beschreibung, wiki_branche, wiki_kategorien, website_summary): + """Ordnet Unternehmen einer Branche aus dem Ziel-Schema zu (via OpenAI).""" + # Stelle sicher, dass das Schema geladen ist + if not ALLOWED_TARGET_BRANCHES: + debug_print("Fehler: Ziel-Branchenschema nicht geladen. Branchenevaluierung übersprungen.") + return {"branch": crm_branche, "consistency": "X", "justification": "Fehler: Ziel-Schema nicht geladen"} + + # Baue den Prompt dynamisch auf, füge nur vorhandene Informationen hinzu + prompt_parts = [TARGET_SCHEMA_STRING] # Beginne mit den Regeln + prompt_parts.append("\nOrdne das Unternehmen anhand folgender Angaben exakt einer Branche des Ziel-Branchenschemas zu:") + if crm_branche and crm_branche != "k.A.": prompt_parts.append(f"- CRM-Branche (Referenz): {crm_branche}") + if beschreibung and beschreibung != "k.A.": prompt_parts.append(f"- Beschreibung: {beschreibung[:500]}") # Kürzen + if wiki_branche and wiki_branche != "k.A.": prompt_parts.append(f"- Wikipedia-Branche: {wiki_branche}") + if wiki_kategorien and wiki_kategorien != "k.A.": prompt_parts.append(f"- Wikipedia-Kategorien: {wiki_kategorien[:500]}") # Kürzen + if website_summary and website_summary != "k.A.": prompt_parts.append(f"- Website-Zusammenfassung: {website_summary[:500]}") # Kürzen + + # Fallback, wenn gar keine Infos da sind + if len(prompt_parts) <= 2: + debug_print("Warnung: Zu wenige Informationen für Branchenevaluierung.") + # Optional: Prompt anpassen oder abbrechen + # prompt_parts.append("- KEINE SPEZIFISCHEN INFORMATIONEN VERFÜGBAR.") + return {"branch": crm_branche, "consistency": "X", "justification": "Zu wenige Informationen"} + + + prompt_parts.append("\nAntworte ausschließlich im folgenden Format (keine Einleitung, kein Schlusssatz):") + prompt_parts.append("Branche: <Vorgeschlagene Branche aus dem Ziel-Schema>") + prompt_parts.append("Übereinstimmung: <ok oder X (Vergleich Vorschlag mit CRM-Referenz)>") + prompt_parts.append("Begründung: <Sehr kurze Begründung für den Vorschlag>") + + prompt = "\n".join(prompt_parts) + + chat_response = call_openai_chat(prompt, temperature=0.0) # Niedrige Temperatur für konsistente Zuordnung + + if not chat_response: + return {"branch": crm_branche, "consistency": "X", "justification": "API-Fehler oder leere Antwort"} + + # Parse die Antwort + lines = chat_response.strip().split("\n") + result = {"branch": "", "consistency": "", "justification": ""} + for line in lines: + if line.lower().startswith("branche:"): + result["branch"] = line.split(":", 1)[1].strip() + elif line.lower().startswith("übereinstimmung:"): + result["consistency"] = line.split(":", 1)[1].strip().lower() + elif line.lower().startswith("begründung:"): + result["justification"] = line.split(":", 1)[1].strip() + + suggested_branch = result["branch"] + + # --- Validierung und Mapping des Vorschlags --- + # 1. Prüfe, ob der Vorschlag *exakt* einer erlaubten Zielbranche entspricht + if suggested_branch in ALLOWED_TARGET_BRANCHES: + final_branch = suggested_branch + debug_print(f"ChatGPT-Branchenvorschlag '{suggested_branch}' ist gültig.") + else: + # 2. Wenn nicht exakt, versuche Mapping über map_external_branch (mit Normalisierung etc.) + mapped_branch = map_external_branch(suggested_branch) + if mapped_branch in ALLOWED_TARGET_BRANCHES: + final_branch = mapped_branch + debug_print(f"ChatGPT-Branchenvorschlag '{suggested_branch}' gemappt zu '{final_branch}'.") + result["justification"] += f" (Hinweis: Vorschlag '{suggested_branch}' wurde zu '{final_branch}' gemappt)" + else: + # 3. Wenn Mapping fehlschlägt, Fallback auf CRM-Branche (falls vorhanden und gültig) + # oder behalte den (ungültigen) Vorschlag und markiere als fehlerhaft + debug_print(f"ChatGPT-Branchenvorschlag '{suggested_branch}' ist ungültig und konnte nicht gemappt werden.") + if crm_branche and crm_branche != "k.A.": # and crm_branche in ALLOWED_TARGET_BRANCHES: # Optional: CRM auch prüfen + final_branch = crm_branche + result["consistency"] = "X" + result["justification"] = f"Fallback: Ungültiger ChatGPT-Vorschlag ('{suggested_branch}'). CRM-Branche '{crm_branche}' verwendet." + else: + final_branch = suggested_branch # Behalte ungültigen Vorschlag + result["consistency"] = "X" + result["justification"] = f"Fehler: Ungültiger ChatGPT-Vorschlag ('{suggested_branch}') und kein gültiger CRM-Fallback." + + result["branch"] = final_branch + + # Konsistenzprüfung explizit neu bewerten basierend auf finalem Branch vs CRM Branch + if final_branch == crm_branche: + result["consistency"] = "ok" + else: + # Hier könnte man noch Fuzzy Similarity einbauen, falls gewünscht + result["consistency"] = "X" + + return result + +# TODO: Weitere ChatGPT-Funktionen (evaluate_fsm_suitability, etc.) analog überarbeiten: +# - Prompts verbessern (klarere Anweisungen, Kontext nur bei Bedarf) +# - call_openai_chat verwenden +# - Parsing der Antworten robuster machen + +def process_wiki_verification(crm_data, wiki_data_str): + # Platzhalter - Implementierung anpassen oder entfernen, falls durch _process_batch abgedeckt + debug_print(f"TODO: process_wiki_verification aufrufen/implementieren für {crm_data}") + return "k.A. (Not Implemented)" + +def evaluate_fsm_suitability(company_name, company_data): + # Platzhalter - Implementierung anpassen + debug_print(f"TODO: evaluate_fsm_suitability aufrufen/implementieren für {company_name}") + return {"suitability": "k.A.", "justification": "Not Implemented"} + +def evaluate_servicetechnicians_estimate(company_name, company_data): + # Platzhalter - Implementierung anpassen + debug_print(f"TODO: evaluate_servicetechnicians_estimate aufrufen/implementieren für {company_name}") + return "k.A. (Not Implemented)" + +def map_internal_technicians(value): + # Platzhalter - Implementierung anpassen + debug_print(f"TODO: map_internal_technicians aufrufen/implementieren für {value}") + return "k.A. (Not Implemented)" + +def evaluate_servicetechnicians_explanation(company_name, st_estimate, company_data): + # Platzhalter - Implementierung anpassen + debug_print(f"TODO: evaluate_servicetechnicians_explanation aufrufen/implementieren für {company_name}") + return "k.A. (Not Implemented)" + +def process_employee_estimation(company_name, wiki_paragraph, crm_employee): + # Platzhalter - Implementierung anpassen + debug_print(f"TODO: process_employee_estimation aufrufen/implementieren für {company_name}") + return "k.A. (Not Implemented)" + +def process_employee_consistency(crm_employee, wiki_employee, emp_estimate): + # Platzhalter - Implementierung anpassen + debug_print(f"TODO: process_employee_consistency aufrufen/implementieren für {crm_employee} vs {wiki_employee} vs {emp_estimate}") + return "k.A. (Not Implemented)" + +def evaluate_umsatz_chatgpt(company_name, wiki_umsatz): + # Platzhalter - Implementierung anpassen + debug_print(f"TODO: evaluate_umsatz_chatgpt aufrufen/implementieren für {company_name}") + return "k.A. (Not Implemented)" + + +# ==================== BATCH PROCESSING FUNCTIONS ==================== + +def _process_batch(sheet, batches, row_numbers): + """ + Hilfsfunktion für process_verification_only: Verarbeitet einen Batch von Wikipedia-Verifizierungsanfragen. + Aktualisiert Spalten S-Y sowie Zeitstempel (AO) und Version (AP). + """ + if not batches: + return + + aggregated_prompt = ( + "Du bist ein Experte in der Verifizierung von Wikipedia-Artikeln für Unternehmen. " + "Für jeden der folgenden Einträge prüfe, ob der vorhandene Wikipedia-Artikel (URL, Absatz, Kategorien) plausibel zum Firmennamen und zur Beschreibung passt. " + "Gib das Ergebnis für jeden Eintrag ausschließlich im folgenden Format auf einer neuen Zeile aus:\n" + "Eintrag <Zeilennummer>: <Antwort>\n\n" + "Mögliche Antworten:\n" + "- 'OK' (wenn der Artikel gut passt)\n" + "- 'X | Alternativer Artikel: <URL> | Begründung: <Kurze Begründung>' (wenn der Artikel nicht passt, aber ein besserer gefunden wurde)\n" + "- 'X | Kein passender Artikel gefunden | Begründung: <Kurze Begründung>' (wenn der Artikel nicht passt und kein besserer gefunden wurde)\n" + "- 'Kein Wikipedia-Eintrag vorhanden.' (wenn initial keine URL angegeben wurde und keine Suche erfolgreich war - dieser Fall sollte selten sein, da die Suche vorher stattfindet)\n\n" + "Einträge:\n" + "----------\n" + ) + aggregated_prompt += "".join(batches) # Join ohne zusätzliches \n + aggregated_prompt += "----------\nBitte nur die 'Eintrag X: Antwort'-Zeilen ausgeben." + + debug_print(f"Verarbeite Verifizierungs-Batch für Zeilen {row_numbers[0]} bis {row_numbers[-1]}.") + + # Token Count für den Prompt + prompt_tokens = token_count(aggregated_prompt) + debug_print(f"Token-Zahl für Verifizierungs-Batch: {prompt_tokens}") + # Optional: Prüfung auf Token-Limit vor dem Senden + # if prompt_tokens > 3800: # Beispiel-Limit für gpt-3.5-turbo (4096 gesamt) + # debug_print(f"WARNUNG: Prompt für Batch {row_numbers[0]}-{row_numbers[-1]} überschreitet möglicherweise Token-Limit ({prompt_tokens}). Überspringe Batch.") + # # Hier könnte man den Batch aufteilen + # return + + chat_response = call_openai_chat(aggregated_prompt, temperature=0.0) + + if not chat_response: + debug_print(f"Fehler: Keine Antwort von OpenAI für Verifizierungs-Batch {row_numbers[0]}-{row_numbers[-1]}.") + # Optional: Markiere Zeilen als fehlerhaft + return + + # Parse die aggregierte Antwort + answers = {} + lines = chat_response.strip().split('\n') + for line in lines: + match = re.match(r"Eintrag (\d+): (.*)", line.strip()) + if match: + row_num = int(match.group(1)) + answer_text = match.group(2).strip() + if row_num in row_numbers: + answers[row_num] = answer_text + else: + debug_print(f"Warnung: Antwort für unerwartete Zeilennummer {row_num} im Batch erhalten: {answer_text}") + + # Bereite Batch-Update für Google Sheet vor + updates = [] + current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + current_version = Config.VERSION + + for row_num in row_numbers: + answer = answers.get(row_num, "k.A. (Keine Antwort im Batch)") # Fallback + debug_print(f"Zeile {row_num} Verifizierungsantwort: '{answer}'") + + wiki_confirm = "" # Spalte S + alt_article = "" # Spalte T + wiki_explanation = "" # Spalte U + # Spalten V-Y bleiben vorerst leer oder werden hier gesetzt + v_val, w_val, x_val, y_val = "", "", "", "" # Beispiel + + if answer.upper() == "OK": + wiki_confirm = "OK" + elif answer.upper() == "KEIN WIKIPEDIA-EINTRAG VORHANDEN.": + wiki_confirm = "X" # Markieren, da eigentlich einer da sein sollte + alt_article = "Kein Wikipedia-Eintrag vorhanden." + wiki_explanation = "Ursprünglich keine URL oder Suche erfolglos." + elif answer.startswith("X |"): + parts = answer.split("|", 2) # Splitte maximal 2 mal + wiki_confirm = "X" + if len(parts) > 1: + detail = parts[1].strip() + if detail.startswith("Alternativer Artikel:"): + alt_article = detail.split(":", 1)[1].strip() + elif detail == "Kein passender Artikel gefunden": + alt_article = "Kein passender Artikel gefunden" + else: # Fallback, falls Format unerwartet + alt_article = detail + if len(parts) > 2: + reason_part = parts[2].strip() + if reason_part.startswith("Begründung:"): + wiki_explanation = reason_part.split(":", 1)[1].strip() + else: # Fallback + wiki_explanation = reason_part + else: # Unerwartetes Format + wiki_confirm = "?" + wiki_explanation = f"Unerwartetes Format: {answer}" + + # Füge Updates für diese Zeile zur Liste hinzu + updates.append({'range': f'S{row_num}', 'values': [[wiki_confirm]]}) + updates.append({'range': f'T{row_num}', 'values': [[alt_article]]}) + updates.append({'range': f'U{row_num}', 'values': [[wiki_explanation]]}) + # Setze V-Y zurück/leer + updates.append({'range': f'V{row_num}:Y{row_num}', 'values': [[v_val, w_val, x_val, y_val]]}) + # Zeitstempel und Version + updates.append({'range': f'AO{row_num}', 'values': [[current_timestamp]]}) + updates.append({'range': f'AP{row_num}', 'values': [[current_version]]}) + + # Führe das Batch-Update für alle Zeilen dieses Batches durch + if updates: + GoogleSheetHandler().batch_update_cells(updates) # Nutze die zentrale Update-Funktion + debug_print(f"Verifizierungs-Batch {row_numbers[0]}-{row_numbers[-1]} erfolgreich in Google Sheet aktualisiert.") + else: + debug_print(f"Keine Updates für Verifizierungs-Batch {row_numbers[0]}-{row_numbers[-1]} generiert.") + + # Kurze Pause nach jedem Batch-API-Call time.sleep(Config.RETRY_DELAY) - +def process_verification_only(sheet_handler, row_limit=None): + """Batch-Prozess nur für Wikipedia-Verifizierung (Modus 51, jetzt 'wiki' im Dispatcher).""" + debug_print("Starte Wikipedia-Verifizierungsmodus (Batch)...") -# ==================== ALIGNMENT DEMO FÜR HAUPTBLATT UND CONTACTS ==================== -def alignment_demo_full(): - alignment_demo(GoogleSheetHandler().sheet) - gc = gspread.authorize(ServiceAccountCredentials.from_json_keyfile_name( - Config.CREDENTIALS_FILE, ["https://www.googleapis.com/auth/spreadsheets"])) - sh = gc.open_by_url(Config.SHEET_URL) - try: - contacts_sheet = sh.worksheet("Contacts") - except gspread.exceptions.WorksheetNotFound: - contacts_sheet = sh.add_worksheet(title="Contacts", rows="1000", cols="10") - header = ["Firmenname", "Website", "Kurzform", "Vorname", "Nachname", "Position", "Anrede", "E-Mail"] - contacts_sheet.update(values=[header], range_name="A1:H1") - debug_print("Neues Blatt 'Contacts' erstellt und Header eingetragen.") - alignment_demo(contacts_sheet) - debug_print("Alignment-Demo für Hauptblatt und Contacts abgeschlossen.") - -# ==================== NEUER MODUS: CONTACT RESEARCH (via SerpAPI) ==================== -def process_contact_research(): - debug_print("Starte Contact Research (Modus 6)...") - gc = gspread.authorize(ServiceAccountCredentials.from_json_keyfile_name( - Config.CREDENTIALS_FILE, ["https://www.googleapis.com/auth/spreadsheets"])) - sh = gc.open_by_url(Config.SHEET_URL) - main_sheet = sh.sheet1 - data = main_sheet.get_all_values() - for i, row in enumerate(data[1:], start=2): - company_name = row[1] if len(row) > 1 else "" - search_name = row[2].strip() if len(row) > 2 and row[2].strip() not in ["", "k.A."] else company_name - website = row[3] if len(row) > 3 else "" - if not company_name or not website: - continue - count_service = count_linkedin_contacts(search_name, website, "Serviceleiter") - count_it = count_linkedin_contacts(search_name, website, "IT-Leiter") - count_management = count_linkedin_contacts(search_name, website, "Geschäftsführer") - count_disponent = count_linkedin_contacts(search_name, website, "Disponent") - current_dt = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - main_sheet.update(values=[[str(count_service)]], range_name=f"AI{i}") - main_sheet.update(values=[[str(count_it)]], range_name=f"AJ{i}") - main_sheet.update(values=[[str(count_management)]], range_name=f"AK{i}") - main_sheet.update(values=[[str(count_disponent)]], range_name=f"AL{i}") - main_sheet.update(values=[[current_dt]], range_name=f"AM{i}") - debug_print(f"Zeile {i}: Serviceleiter {count_service}, IT-Leiter {count_it}, Management {count_management}, Disponent {count_disponent} – Contact Search Timestamp gesetzt.") - time.sleep(Config.RETRY_DELAY * 1.5) - debug_print("Contact Research abgeschlossen.") - -# ==================== NEUER MODUS: CONTACTS (LinkedIn) ==================== -def process_contacts(): - debug_print("Starte LinkedIn-Kontaktsuche...") - gc = gspread.authorize(ServiceAccountCredentials.from_json_keyfile_name( - Config.CREDENTIALS_FILE, ["https://www.googleapis.com/auth/spreadsheets"])) - sh = gc.open_by_url(Config.SHEET_URL) - try: - contacts_sheet = sh.worksheet("Contacts") - except gspread.exceptions.WorksheetNotFound: - contacts_sheet = sh.add_worksheet(title="Contacts", rows="1000", cols="10") - header = ["Firmenname", "Website", "Kurzform", "Vorname", "Nachname", "Position", "Anrede", "E-Mail"] - contacts_sheet.update(values=[header], range_name="A1:H1") - debug_print("Neues Blatt 'Contacts' erstellt und Header eingetragen.") - alignment_demo(contacts_sheet) - debug_print("Alignment-Demo für Contacts abgeschlossen.") - # Weitere Verarbeitung der Kontakte folgt hier ... - -# ==================== LINKEDIN HELPER ==================== -def search_linkedin_contacts(company_name, website, position_query, crm_kurzform, num_results=100): - """ - Sucht über SERPAPI mehrere LinkedIn-Kontakte basierend auf der Positionsbezeichnung - und der CRM-Kurzform des Unternehmens. Es werden alle Treffer zurückgegeben, bei denen - die CRM-Kurzform (als Teilstring) im Titel auftaucht. + all_data = sheet_handler.get_all_data_with_headers() # Hole alle Daten inkl. Header + header_rows = 5 # Zeilen 1-5 sind Header - Args: - company_name (str): Der Firmenname. - website (str): Die Website des Unternehmens. - position_query (str): Die zu suchende Positionsbezeichnung (z. B. "Serviceleiter"). - crm_kurzform (str): Die manuell gepflegte Kurzform des Firmennamens. - num_results (int): Anzahl der abzurufenden Suchergebnisse (hier standardmäßig 100). + # Finde Startzeile (erste Zeile ab Zeile 7 ohne Zeitstempel in AO) + start_row_index_in_sheet = -1 # 1-basierter Index im Sheet + for i in range(header_rows + 1, len(all_data) + 1): # Starte Prüfung ab Zeile 6 (Index 5) + if i < 7: continue # Überspringe Zeilen vor 7 + + row_index_in_list = i - 1 # 0-basierter Index in all_data + row = all_data[row_index_in_list] + # Prüfe Zeitstempel in Spalte AO (Index 40) + if len(row) <= COLUMN_MAP["Timestamp letzte Prüfung"] or not row[COLUMN_MAP["Timestamp letzte Prüfung"]].strip(): + start_row_index_in_sheet = i + break + + if start_row_index_in_sheet == -1: + debug_print("Keine Zeile ohne Zeitstempel in Spalte AO (ab Zeile 7) gefunden. Verifizierung übersprungen.") + return + + debug_print(f"Verarbeitung startet ab Zeile {start_row_index_in_sheet} (erste Zeile ab 7 ohne Zeitstempel in AO).") + + # Bestimme Endzeile basierend auf row_limit + if row_limit is not None and row_limit > 0: + end_row_index_in_sheet = min(start_row_index_in_sheet + row_limit - 1, len(all_data)) + else: + end_row_index_in_sheet = len(all_data) # Bis zum Ende des Sheets + + if start_row_index_in_sheet > end_row_index_in_sheet: + debug_print("Startzeile liegt nach der Endzeile. Keine Verarbeitung.") + return + + debug_print(f"Verarbeite Zeilen von {start_row_index_in_sheet} bis {end_row_index_in_sheet}.") + + batch_size = Config.BATCH_SIZE + current_batch = [] + current_row_numbers = [] + + for i in range(start_row_index_in_sheet, end_row_index_in_sheet + 1): + row_index_in_list = i - 1 + row = all_data[row_index_in_list] - Returns: - list: Eine Liste von Dictionaries mit den Kontaktdaten (Vorname, Nachname, Position, LinkedInURL) - oder eine leere Liste, wenn keine Treffer gefunden wurden. - """ - try: - with open("serpApiKey.txt", "r") as f: - serp_key = f.read().strip() - except Exception as e: - debug_print("Fehler beim Lesen des SerpAPI-Schlüssels: " + str(e)) - return [] + # Erstelle Text für den Prompt (verwende Spaltennamen/Indizes) + # Annahme: COLUMN_MAP ist verfügbar und korrekt + company_name = row[COLUMN_MAP["CRM Name"]] if len(row) > COLUMN_MAP["CRM Name"] else '' + crm_desc = row[COLUMN_MAP["CRM Beschreibung"]] if len(row) > COLUMN_MAP["CRM Beschreibung"] else '' + wiki_url = row[COLUMN_MAP["Wiki URL"]] if len(row) > COLUMN_MAP["Wiki URL"] and row[COLUMN_MAP["Wiki URL"]].strip() not in ['', 'k.A.'] else 'k.A.' + wiki_paragraph = row[COLUMN_MAP["Wiki Absatz"]] if len(row) > COLUMN_MAP["Wiki Absatz"] else 'k.A.' + wiki_categories = row[COLUMN_MAP["Wiki Kategorien"]] if len(row) > COLUMN_MAP["Wiki Kategorien"] else 'k.A.' + + entry_text = ( + f"Eintrag {i}:\n" + f" Firmenname: {company_name}\n" + f" CRM-Beschreibung: {crm_desc[:200]}...\n" # Gekürzt + f" Wikipedia-URL: {wiki_url}\n" + f" Wiki-Absatz: {wiki_paragraph[:200]}...\n" # Gekürzt + f" Wiki-Kategorien: {wiki_categories[:200]}...\n" # Gekürzt + f"----\n" + ) + current_batch.append(entry_text) + current_row_numbers.append(i) + + # Wenn Batch voll oder letzte Zeile erreicht + if len(current_batch) == batch_size or i == end_row_index_in_sheet: + _process_batch(sheet_handler.sheet, current_batch, current_row_numbers) + # Reset für nächsten Batch + current_batch = [] + current_row_numbers = [] + + debug_print("Wikipedia-Verifizierungs-Batch abgeschlossen.") + + +def process_website_batch(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet): + """Batch-Prozess für Website-Scraping (Rohtext & Zusammenfassung).""" + debug_print(f"Starte Website-Scraping (Batch) für Zeilen {start_row_index_in_sheet} bis {end_row_index_in_sheet}...") - query = f'site:linkedin.com/in "{position_query}" "{company_name}"' + all_data = sheet_handler.get_all_data_with_headers() + sheet = sheet_handler.sheet # Direkter Zugriff auf das Sheet-Objekt + + for i in range(start_row_index_in_sheet, end_row_index_in_sheet + 1): + row_index_in_list = i - 1 + row = all_data[row_index_in_list] + + # TODO: Hier prüfen, ob Verarbeitung übersprungen werden soll (z.B. Zeitstempel schon vorhanden?) + # if len(row) > COLUMN_MAP["Timestamp letzte Prüfung"] and row[COLUMN_MAP["Timestamp letzte Prüfung"]].strip(): + # debug_print(f"Zeile {i}: Überspringe Website-Scraping (Zeitstempel vorhanden).") + # continue + + website_url = row[COLUMN_MAP["CRM Website"]] if len(row) > COLUMN_MAP["CRM Website"] else "" + if not website_url or website_url.strip().lower() == "k.a.": + debug_print(f"Zeile {i}: Kein gültiger Website-Eintrag, überspringe Website-Scraping.") + # Optional: Zeitstempel trotzdem setzen? + # sheet.update_cell(i, COLUMN_MAP["Timestamp letzte Prüfung"] + 1, datetime.now().strftime("%Y-%m-%d %H:%M:%S")) + # sheet.update_cell(i, COLUMN_MAP["Version"] + 1, Config.VERSION) + continue + + debug_print(f"Zeile {i}: Verarbeite Website {website_url}...") + raw_text = get_website_raw(website_url) + summary = summarize_website_content(raw_text) + + updates = [] + current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + current_version = Config.VERSION + + updates.append({'range': f'AR{i}', 'values': [[raw_text]]}) # Spalte AR + updates.append({'range': f'AS{i}', 'values': [[summary]]}) # Spalte AS + updates.append({'range': f'AO{i}', 'values': [[current_timestamp]]}) # Spalte AO + updates.append({'range': f'AP{i}', 'values': [[current_version]]}) # Spalte AP + + # Führe Batch-Update für diese eine Zeile durch + if updates: + sheet_handler.batch_update_cells(updates) + debug_print(f"Zeile {i}: Website-Daten aktualisiert | Zeitstempel: {current_timestamp}, Version: {current_version}") + + # Pause zwischen den Zeilen/Websites + time.sleep(Config.RETRY_DELAY) + + debug_print("Website-Scraping (Batch) abgeschlossen.") + + +def process_branch_batch(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet): + """Batch-Prozess für Brancheneinschätzung.""" + debug_print(f"Starte Brancheneinschätzung (Batch) für Zeilen {start_row_index_in_sheet} bis {end_row_index_in_sheet}...") + + all_data = sheet_handler.get_all_data_with_headers() + sheet = sheet_handler.sheet + + # Stelle sicher, dass das Branchenschema geladen ist + if not ALLOWED_TARGET_BRANCHES: + load_target_schema() # Versuch es zu laden + if not ALLOWED_TARGET_BRANCHES: + debug_print("FEHLER: Ziel-Branchenschema konnte nicht geladen werden. Breche Branch-Batch ab.") + return + + for i in range(start_row_index_in_sheet, end_row_index_in_sheet + 1): + row_index_in_list = i - 1 + row = all_data[row_index_in_list] + + # TODO: Zeitstempelprüfung zum Überspringen? + # if len(row) > COLUMN_MAP["Timestamp letzte Prüfung"] and row[COLUMN_MAP["Timestamp letzte Prüfung"]].strip(): + # debug_print(f"Zeile {i}: Überspringe Branchen-Einschätzung (Zeitstempel vorhanden).") + # continue + + # Hole benötigte Daten aus der Zeile (verwende COLUMN_MAP) + crm_branche = row[COLUMN_MAP["CRM Branche"]] if len(row) > COLUMN_MAP["CRM Branche"] else "" + beschreibung = row[COLUMN_MAP["CRM Beschreibung"]] if len(row) > COLUMN_MAP["CRM Beschreibung"] else "" + wiki_branche = row[COLUMN_MAP["Wiki Branche"]] if len(row) > COLUMN_MAP["Wiki Branche"] else "" + wiki_kategorien = row[COLUMN_MAP["Wiki Kategorien"]] if len(row) > COLUMN_MAP["Wiki Kategorien"] else "" + # Nimm Website Zusammenfassung aus Spalte AS (Index 44) + website_summary = row[COLUMN_MAP["Website Zusammenfassung"]] if len(row) > COLUMN_MAP["Website Zusammenfassung"] else "" + + debug_print(f"Zeile {i}: Starte Brancheneinschätzung...") + result = evaluate_branche_chatgpt(crm_branche, beschreibung, wiki_branche, wiki_kategorien, website_summary) + + updates = [] + current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + current_version = Config.VERSION + + updates.append({'range': f'W{i}', 'values': [[result.get("branch", "Fehler")]]}) # Spalte W + updates.append({'range': f'X{i}', 'values': [[result.get("consistency", "Fehler")]]}) # Spalte X + updates.append({'range': f'Y{i}', 'values': [[result.get("justification", "Fehler")]]}) # Spalte Y + updates.append({'range': f'AO{i}', 'values': [[current_timestamp]]}) # Spalte AO + updates.append({'range': f'AP{i}', 'values': [[current_version]]}) # Spalte AP + + # Führe Batch-Update für diese eine Zeile durch + if updates: + sheet_handler.batch_update_cells(updates) + debug_print(f"Zeile {i}: Branch-Einschätzung aktualisiert: {result} | Zeitstempel: {current_timestamp}, Version: {current_version}") + + # Pause zwischen den API-Aufrufen + time.sleep(Config.RETRY_DELAY) + + debug_print("Brancheneinschätzung (Batch) abgeschlossen.") + + +def run_dispatcher(mode, sheet_handler, row_limit=None): + """Wählt den passenden Batch-Prozess basierend auf dem Modus.""" + debug_print(f"Starte Dispatcher im Modus '{mode}' mit row_limit={row_limit}.") + + # Finde Startzeile (erste Zeile ab 7 ohne Zeitstempel in AO) + data = sheet_handler.get_all_data_with_headers() + header_rows = 5 + start_row_index_in_sheet = -1 + for i in range(header_rows + 1, len(data) + 1): + if i < 7: continue + row_index_in_list = i - 1 + row = data[row_index_in_list] + if len(row) <= COLUMN_MAP["Timestamp letzte Prüfung"] or not row[COLUMN_MAP["Timestamp letzte Prüfung"]].strip(): + start_row_index_in_sheet = i + break + + if start_row_index_in_sheet == -1: + debug_print("Keine Zeile ohne Zeitstempel in Spalte AO (ab Zeile 7) gefunden. Dispatcher beendet.") + return + + # Bestimme Endzeile + if row_limit is not None and row_limit > 0: + end_row_index_in_sheet = min(start_row_index_in_sheet + row_limit - 1, len(data)) + else: + end_row_index_in_sheet = len(data) + + debug_print(f"Dispatcher: Verarbeitung startet ab Zeile {start_row_index_in_sheet}, bis Zeile {end_row_index_in_sheet}.") + + if start_row_index_in_sheet > end_row_index_in_sheet: + debug_print("Startzeile liegt nach Endzeile. Keine Verarbeitung.") + return + + # Modus auswählen + if mode == "wiki": + process_verification_only(sheet_handler, row_limit) # Nutzt jetzt row_limit intern anders + elif mode == "website": + process_website_batch(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet) + elif mode == "branch": + process_branch_batch(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet) + elif mode == "combined": + debug_print("--- Start Combined Mode: Wiki ---") + process_verification_only(sheet_handler, row_limit) + debug_print("--- Start Combined Mode: Website ---") + # Website und Branch brauchen evtl. aktualisierte Daten nach Wiki -> neu laden? Oder mit alten Daten arbeiten? + # Annahme: Arbeite erstmal mit den Daten wie sie sind. Start/End Row bleiben gleich. + process_website_batch(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet) + debug_print("--- Start Combined Mode: Branch ---") + process_branch_batch(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet) + debug_print("--- Combined Mode abgeschlossen ---") + else: + debug_print(f"Ungültiger Modus '{mode}' im Dispatcher.") + +# ==================== SERP API / LINKEDIN FUNCTIONS ==================== + +@retry_on_failure +def serp_website_lookup(company_name): + """Ermittelt Website via SERP API (Google Suche).""" + serp_key = Config.API_KEYS.get('serpapi') + if not serp_key: + debug_print("Fehler: SerpAPI Key nicht verfügbar für Website Lookup.") + return "k.A." + if not company_name: return "k.A." + + # Blacklist unerwünschter Domains + blacklist = ["bloomberg.com", "northdata.de", "finanzen.net", "handelsblatt.com", "wikipedia.org", "linkedin.com"] + + query = f'{company_name} offizielle Website' # Präzisere Query params = { "engine": "google", "q": query, "api_key": serp_key, "hl": "de", - "num": num_results + "gl": "de" # Geolocation auf Deutschland setzen } - - # Logge die vollständige Such-URL - request_url = "https://serpapi.com/search?" + urlencode(params) - debug_print(f"Such-URL: {request_url}") + api_url = "https://serpapi.com/search" try: - response = requests.get("https://serpapi.com/search", params=params, timeout=10) + response = requests.get(api_url, params=params, timeout=10) + response.raise_for_status() data = response.json() - contacts = [] - if "organic_results" in data and len(data["organic_results"]) > 0: + + # 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"]["website"] + if kg_url and not any(bad_domain in kg_url for bad_domain in blacklist): + normalized_url = simple_normalize_url(kg_url) + if normalized_url != "k.A.": + debug_print(f"SERP Lookup: Website '{normalized_url}' aus Knowledge Graph für '{company_name}' gefunden.") + return normalized_url + + # 2. Organische Ergebnisse prüfen + if "organic_results" in data: for result in data["organic_results"]: - title = result.get("title", "") - if crm_kurzform.lower() in title.lower(): - if "–" in title: - parts = title.split("–") - elif "-" in title: - parts = title.split("-") - else: - parts = [title] - if len(parts) >= 2: - name_part = parts[0].strip() - pos_part = parts[1].split("|")[0].strip() - name_parts = name_part.split(" ", 1) - if len(name_parts) == 2: - firstname, lastname = name_parts - else: - firstname = name_part - lastname = "" - linkedin_url = result.get("link", "") - debug_print(f"Gefundener Kontakt: {firstname} {lastname}, Position: {pos_part}") - contacts.append({ - "Firmenname": company_name, - "Website": website, - "Vorname": firstname, - "Nachname": lastname, - "Position": pos_part, - "LinkedInURL": linkedin_url - }) - if not contacts: - debug_print("Kein Treffer mit CRM-Kurzform in Titel gefunden.") - else: - debug_print("Keine organic_results für Query gefunden.") - return contacts + url = result.get("link", "") + # Prüfe Blacklist und ob es eine "echte" Website ist (nicht nur Suche etc.) + if url and not any(bad_domain in url for bad_domain in blacklist) and url.startswith("http"): + normalized_url = simple_normalize_url(url) + if normalized_url != "k.A.": + # Zusätzliche Plausibilitätsprüfung: Enthält die Domain Teile des Firmennamens? + domain_part = normalized_url.replace('www.', '').split('.')[0] + if domain_part in normalize_company_name(company_name): + debug_print(f"SERP Lookup: Website '{normalized_url}' aus Organic Results für '{company_name}' gefunden.") + return normalized_url + else: + debug_print(f"SERP Lookup: URL '{normalized_url}' übersprungen (Domain passt nicht zu '{company_name}').") + + debug_print(f"SERP Lookup: Keine passende Website für '{company_name}' gefunden.") + return "k.A." + except requests.exceptions.RequestException as e: + debug_print(f"Fehler beim SERP API Website Lookup für '{company_name}': {e}") + return "k.A." except Exception as e: - debug_print(f"Fehler bei der SERPAPI-Suche: {e}") + debug_print(f"Allgemeiner Fehler beim SERP API Website Lookup für '{company_name}': {e}") + return "k.A." + + +@retry_on_failure +def search_linkedin_contacts(company_name, website, position_query, crm_kurzform, num_results=10): + """Sucht LinkedIn Kontakte via SERP API.""" + serp_key = Config.API_KEYS.get('serpapi') + if not serp_key: + debug_print("Fehler: SerpAPI Key nicht verfügbar für LinkedIn Suche.") + return [] + if not all([company_name, position_query, crm_kurzform]): return [] -def count_linkedin_contacts(company_name, website, position_query, crm_kurzform): - """ - Zählt über SERPAPI, wie viele LinkedIn-Kontakte für einen bestimmten Positionsbegriff existieren, - wobei als Filter zusätzlich geprüft wird, ob der Titel Teile der CRM-Kurzform enthält. - - Args: - company_name (str): Firmenname. - website (str): Website des Unternehmens. - position_query (str): Gewünschte Position (z. B. "Serviceleiter"). - crm_kurzform (str): Kurzform des Firmennamens. - - Returns: - int: Anzahl der Treffer, die den Filter erfüllen. - """ - try: - with open("serpApiKey.txt", "r") as f: - serp_key = f.read().strip() - except Exception as e: - debug_print("Fehler beim Lesen des SerpAPI-Schlüssels: " + str(e)) - return 0 - - query = f'site:linkedin.com/in "{position_query}" "{company_name}"' + # Query anpassen für bessere Ergebnisse + query = f'site:linkedin.com/in "{position_query}" "{crm_kurzform}"' # Suche nach Kurzform im Titel + # query = f'site:linkedin.com/in "{position_query}" "{company_name}"' # Original Query params = { "engine": "google", "q": query, "api_key": serp_key, - "hl": "de" + "hl": "de", + "gl": "de", + "num": num_results # Google's num Parameter (max 100, aber oft weniger geliefert) } + api_url = "https://serpapi.com/search" + try: - response = requests.get("https://serpapi.com/search", params=params, timeout=10) + response = requests.get(api_url, params=params, timeout=15) # Längerer Timeout + response.raise_for_status() data = response.json() - count = 0 + contacts = [] + if "organic_results" in data: for result in data["organic_results"]: title = result.get("title", "") - if crm_kurzform.lower() in title.lower(): - count += 1 - debug_print(f"Anzahl Kontakte für Query '{query}' mit CRM-Kurzform-Filter: {count}") - return count - else: - debug_print(f"Keine Ergebnisse für Query: {query}") - return 0 - except Exception as e: - debug_print(f"Fehler bei der SerpAPI-Suche (Count): {e}") - return 0 + linkedin_url = result.get("link", "") + + # Filter: Muss LinkedIn URL sein und Kurzform muss im Titel vorkommen + if not linkedin_url or "linkedin.com/in/" not in linkedin_url: + continue + if crm_kurzform.lower() not in title.lower(): + debug_print(f"LinkedIn Treffer übersprungen: Kurzform '{crm_kurzform}' nicht in Titel '{title}'") + continue - -def search_linkedin_contact(company_name, website, position_query, crm_kurzform): - """ - Sucht über SERPAPI einen einzelnen LinkedIn-Kontakt basierend auf der Positionsbezeichnung und der CRM-Kurzform des Unternehmens. - Es wird nur ein Treffer zurückgegeben, wenn der Titel auch die CRM-Kurzform (als Teilstring) enthält. - - Args: - company_name (str): Der Firmenname. - website (str): Die Website des Unternehmens. - position_query (str): Die zu suchende Positionsbezeichnung (z. B. "Serviceleiter"). - crm_kurzform (str): Die manuell gepflegte Kurzform des Firmennamens. - - Returns: - dict oder None: Ein Dictionary mit den Kontaktdaten (Vorname, Nachname, Position, LinkedInURL) oder None, falls kein passender Kontakt gefunden wurde. - """ - try: - with open("serpApiKey.txt", "r") as f: - serp_key = f.read().strip() - except Exception as e: - debug_print("Fehler beim Lesen des SerpAPI-Schlüssels: " + str(e)) - return None - - query = f'site:linkedin.com/in "{position_query}" "{company_name}"' - params = { - "engine": "google", - "q": query, - "api_key": serp_key, - "hl": "de" - } - try: - response = requests.get("https://serpapi.com/search", params=params, timeout=10) - data = response.json() - if "organic_results" in data and len(data["organic_results"]) > 0: - # Gehe die Ergebnisse durch und prüfe, ob der Titel den crm_kurzform-String enthält - for result in data["organic_results"]: - title = result.get("title", "") - if crm_kurzform.lower() in title.lower(): - # Aufteilen des Titels in Namens- und Positionsbestandteile - if "–" in title: - parts = title.split("–") - elif "-" in title: - parts = title.split("-") - else: - parts = [title] - if len(parts) >= 2: + # Extrahiere Name und Position aus Titel + name_part = "" + pos_part = position_query # Fallback auf Suchbegriff + + # Versuche gängige Trennzeichen + separators = ["–", "-", "|", " at ", " bei "] + title_cleaned = title.replace("...", "").strip() # Bereinige Titel + + found_sep = False + for sep in separators: + if sep in title_cleaned: + parts = title_cleaned.split(sep, 1) name_part = parts[0].strip() - pos_part = parts[1].split("|")[0].strip() - name_parts = name_part.split(" ", 1) - if len(name_parts) == 2: - firstname, lastname = name_parts - else: - firstname = name_part - lastname = "" - linkedin_url = result.get("link", "") # LinkedIn-Profil-Link aus dem Ergebnis - debug_print(f"Gefundener Kontakt: {firstname} {lastname}, Position: {pos_part}") - return { - "Firmenname": company_name, - "Website": website, - "Vorname": firstname, - "Nachname": lastname, - "Position": pos_part, - "LinkedInURL": linkedin_url - } - debug_print("Kein Treffer mit CRM-Kurzform in Titel gefunden.") - return None - else: - debug_print("Keine organic_results für Query gefunden.") - return None - except Exception as e: - debug_print(f"Fehler bei der SerpAPI-Suche: {e}") - return None + # Versuche, LinkedIn/Profil etc. aus Namen zu entfernen + name_part = name_part.replace(" | LinkedIn", "").replace(" - LinkedIn", "").replace(" - Profil", "").strip() + + # Positionsteil kann komplex sein, nehme alles nach dem Trenner + potential_pos = parts[1].strip() + # Entferne Firmenteil, wenn er dem Kurznamen ähnelt + if crm_kurzform.lower() in potential_pos.lower(): + potential_pos = potential_pos.replace(crm_kurzform, "", 1).strip() # Nur erste Instanz ersetzen + # Entferne generische Endungen + potential_pos = potential_pos.split(" | LinkedIn")[0].split(" - LinkedIn")[0].strip() + pos_part = potential_pos if potential_pos else position_query + found_sep = True + break + + if not found_sep: # Kein Trennzeichen gefunden + name_part = title_cleaned.split(" | LinkedIn")[0].split(" - LinkedIn")[0].strip() + # Prüfe, ob der Suchbegriff im verbleibenden Namensteil ist + if position_query.lower() in name_part.lower(): + name_part = name_part.replace(position_query, "", 1).strip() # Versuche Position zu entfernen -def count_linkedin_contacts(company_name, website, position_query, crm_kurzform): - """ - Zählt über SERPAPI, wieviele LinkedIn-Kontakte für einen bestimmten Positionsbegriff existieren, - wobei als Filter zusätzlich geprüft wird, ob der Titel Teile der CRM-Kurzform enthält. + # Teile Namen in Vor- und Nachname + 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: # Wenn Name nicht extrahiert werden konnte, überspringe + debug_print(f"Kontakt übersprungen: Name konnte nicht extrahiert werden aus Titel '{title}'") + continue + + contact_data = { + "Firmenname": company_name, # Originalname für Kontext + "CRM Kurzform": crm_kurzform, + "Website": website, + "Vorname": firstname, + "Nachname": lastname, + "Position": pos_part, + "LinkedInURL": linkedin_url + } + contacts.append(contact_data) + debug_print(f"Gefundener LinkedIn Kontakt: {firstname} {lastname} - {pos_part}") + + debug_print(f"LinkedIn Suche für '{position_query}' bei '{crm_kurzform}' ergab {len(contacts)} Kontakte.") + return contacts + + except requests.exceptions.RequestException as e: + debug_print(f"Fehler bei der SERP API LinkedIn Suche: {e}") + return [] + except Exception as e: + debug_print(f"Allgemeiner Fehler bei der SERP API LinkedIn Suche: {e}") + return [] + + +# Funktion count_linkedin_contacts wurde entfernt, da search_linkedin_contacts jetzt die Liste liefert +# und len() darauf angewendet werden kann. + + +def process_contact_research(sheet_handler): + """Sucht LinkedIn Kontakte und trägt sie in 'Contacts' Sheet ein.""" + debug_print("Starte Contact Research (LinkedIn)...") - Args: - company_name (str): Firmenname. - website (str): Website des Unternehmens. - position_query (str): Gewünschte Position (z. B. "Serviceleiter"). - crm_kurzform (str): Kurzform des Firmennamens. - - Returns: - int: Anzahl der Treffer, die den Filter erfüllen. - """ - try: - with open("serpApiKey.txt", "r") as f: - serp_key = f.read().strip() - except Exception as e: - debug_print("Fehler beim Lesen des SerpAPI-Schlüssels: " + str(e)) - return 0 - - query = f'site:linkedin.com/in "{position_query}" "{company_name}"' - params = { - "engine": "google", - "q": query, - "api_key": serp_key, - "hl": "de" - } - try: - response = requests.get("https://serpapi.com/search", params=params, timeout=10) - data = response.json() - count = 0 - if "organic_results" in data: - for result in data["organic_results"]: - title = result.get("title", "") - if crm_kurzform.lower() in title.lower(): - count += 1 - debug_print(f"Anzahl Kontakte für Query '{query}' mit CRM-Kurzform-Filter: {count}") - return count - else: - debug_print(f"Keine Ergebnisse für Query: {query}") - return 0 - except Exception as e: - debug_print(f"Fehler bei der SerpAPI-Suche (Count): {e}") - return 0 - -def process_contact_research(): - """ - Sucht mithilfe der SERPAPI Kontakte für bestimmte Positionen für jedes Unternehmen. - Die gefundenen Kontakte werden im Kontakte-Blatt eingetragen – pro Kategorie werden alle - Treffer (die den Filter (CRM-Kurzform muss im Titel enthalten sein) erfüllen) verarbeitet. + main_sheet = sheet_handler.sheet + all_data = sheet_handler.get_all_data_with_headers() + header_rows = 5 - Im Kontakte-Blatt wird folgende Spaltenstruktur verwendet: - A: Firmenname - B: CRM Kurzform - C: Website - D: Geschlecht - E: Vorname - F: Nachname - G: Position - H: Suchbegriffskategorie - I: E-Mail-Adresse - J: LinkedIn-Link - K: Timestamp - """ - debug_print("Starte Contact Research (Modus 6)...") - # Verbinde zum Hauptblatt - gc = gspread.authorize(ServiceAccountCredentials.from_json_keyfile_name( - Config.CREDENTIALS_FILE, ["https://www.googleapis.com/auth/spreadsheets"])) - sh = gc.open_by_url(Config.SHEET_URL) - main_sheet = sh.sheet1 - data = main_sheet.get_all_values() - - # Ermittle die letzte Zeile in Spalte AM (Spalte 39) mit einem Timestamp - col_am = main_sheet.col_values(39) - last_filled_row = 1 - for idx, cell in enumerate(col_am): - if cell.strip() != "": - last_filled_row = idx + 1 - start_row = last_filled_row + 1 - debug_print(f"Letzter Timestamp in Spalte AM in Zeile {last_filled_row}. Starte Verarbeitung ab Zeile {start_row}.") - - if start_row > len(data): - debug_print("Keine neuen Zeilen zu verarbeiten, da Timestamp in Spalte AM bis zum Ende vorhanden ist.") + # Finde Startzeile basierend auf Timestamp in Spalte AM (Index 38) + timestamp_col_index = COLUMN_MAP["Contact Search Timestamp"] + start_row_index_in_sheet = -1 + for i in range(header_rows + 1, len(all_data) + 1): + if i < 7: continue # Normalerweise ab Zeile 7 + row_index_in_list = i - 1 + row = all_data[row_index_in_list] + if len(row) <= timestamp_col_index or not row[timestamp_col_index].strip(): + start_row_index_in_sheet = i + break + + if start_row_index_in_sheet == -1: + debug_print("Keine Zeile ohne Contact Search Timestamp (Spalte AM, ab Zeile 7) gefunden. Überspringe.") return - # Kontakte-Blatt öffnen oder erstellen (Header: A-K) + debug_print(f"Contact Research startet ab Zeile {start_row_index_in_sheet}.") + + # Kontakte-Blatt öffnen oder erstellen try: - contacts_sheet = sh.worksheet("Contacts") + contacts_sheet = sheet_handler.sheet.spreadsheet.worksheet("Contacts") + debug_print("Blatt 'Contacts' gefunden.") except gspread.exceptions.WorksheetNotFound: - contacts_sheet = sh.add_worksheet(title="Contacts", rows="1000", cols="12") + debug_print("Blatt 'Contacts' nicht gefunden, erstelle neu...") + contacts_sheet = sheet_handler.sheet.spreadsheet.add_worksheet(title="Contacts", rows="1000", cols="12") header = ["Firmenname", "CRM Kurzform", "Website", "Geschlecht", "Vorname", "Nachname", "Position", "Suchbegriffskategorie", "E-Mail-Adresse", "LinkedIn-Link", "Timestamp"] contacts_sheet.update(values=[header], range_name="A1:K1") + # Optional: Alignment Demo hier nicht mehr aufrufen + # alignment_demo(contacts_sheet) # NICHT MEHR NÖTIG/FALSCH debug_print("Neues Blatt 'Contacts' erstellt und Header eingetragen.") - # Gehe alle Zeilen im Hauptblatt ab der Startzeile durch - for i in range(start_row, len(data) + 1): - row = data[i - 1] - company_name = row[1] if len(row) > 1 else "" - crm_kurzform = row[2] if len(row) > 2 else "" - website = row[3] if len(row) > 3 else "" - if not company_name or not website or not crm_kurzform: - debug_print(f"Zeile {i}: Fehlende essentielle CRM-Daten, überspringe.") + # Positionen, nach denen gesucht wird + positions_to_search = ["Serviceleiter", "Leiter Kundendienst", "IT-Leiter", "Leiter IT", "Geschäftsführer", "Vorstand", "Disponent", "Einsatzleiter"] + + # Gehe Zeilen im Hauptblatt durch + for i in range(start_row_index_in_sheet, len(all_data) + 1): + row_index_in_list = i - 1 + row = all_data[row_index_in_list] + + company_name = row[COLUMN_MAP["CRM Name"]] if len(row) > COLUMN_MAP["CRM Name"] else "" + crm_kurzform = row[COLUMN_MAP["CRM Kurzform"]] if len(row) > COLUMN_MAP["CRM Kurzform"] else "" + website = row[COLUMN_MAP["CRM Website"]] if len(row) > COLUMN_MAP["CRM Website"] else "" + + if not all([company_name, crm_kurzform, website]): + debug_print(f"Zeile {i}: Übersprungen (fehlende CRM Daten: Name, Kurzform oder Website).") continue - positions = ["Serviceleiter", "IT-Leiter", "Geschäftsführer", "Disponent"] - contact_counts = {} - for pos in positions: - count = count_linkedin_contacts(crm_kurzform, website, pos, crm_kurzform) - contact_counts[pos] = count - # Abfrage: Es sollen nun alle Treffer (bis zu 100) verarbeitet werden - contacts = search_linkedin_contacts(crm_kurzform, website, pos, crm_kurzform, num_results=100) - for contact in contacts: - firstname = contact.get("Vorname", "") - lastname = contact.get("Nachname", "") - gender_value = get_gender(firstname) if firstname else "unknown" - email = get_email_address(firstname, lastname, website) - timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - # Spaltenanordnung: A: Firmenname, B: CRM Kurzform, C: Website, D: Geschlecht, E: Vorname, - # F: Nachname, G: Position, H: Suchbegriffskategorie, I: E-Mail-Adresse, J: LinkedIn-Link, K: Timestamp - contact_row = [ - company_name, - crm_kurzform, - website, - gender_value, - firstname, - lastname, - contact.get("Position", ""), - pos, - email, - contact.get("LinkedInURL", ""), - timestamp - ] - try: - contacts_sheet.append_row(contact_row) - debug_print(f"Zeile {i}: Kontakt für '{pos}' gespeichert: {contact_row}") - except Exception as e: - debug_print(f"Zeile {i}: Fehler beim Speichern des Kontakts für '{pos}': {e}") - # Aktualisiere Trefferzahlen und Timestamp im Hauptblatt - try: - main_sheet.update(values=[[str(contact_counts.get("Serviceleiter", 0))]], range_name=f"AI{i}") - main_sheet.update(values=[[str(contact_counts.get("IT-Leiter", 0))]], range_name=f"AJ{i}") - main_sheet.update(values=[[str(contact_counts.get("Geschäftsführer", 0))]], range_name=f"AK{i}") - main_sheet.update(values=[[str(contact_counts.get("Disponent", 0))]], range_name=f"AL{i}") - timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - main_sheet.update(values=[[timestamp]], range_name=f"AM{i}") - debug_print(f"Zeile {i}: Kontaktzahlen aktualisiert: {contact_counts} – Timestamp in AM gesetzt.") - except Exception as e: - debug_print(f"Zeile {i}: Fehler beim Aktualisieren der CRM-Kontaktsummen: {e}") + debug_print(f"Zeile {i}: Suche Kontakte für '{crm_kurzform}'...") + all_found_contacts = [] + contact_counts = {pos: 0 for pos in ["Serviceleiter", "IT-Leiter", "Geschäftsführer", "Disponent"]} # Für die Zählung im Hauptblatt + + for position in positions_to_search: + # Suche max. 5 Kontakte pro Position, um API Calls/Kosten zu begrenzen + found_contacts = search_linkedin_contacts(company_name, website, position, crm_kurzform, num_results=5) + + # Zählung für das Hauptblatt (vereinfachte Kategorien) + if "serviceleiter" in position.lower() or "kundendienst" in position.lower() or "einsatzleiter" in position.lower(): + contact_counts["Serviceleiter"] += len(found_contacts) + elif "it-leiter" in position.lower() or "leiter it" in position.lower(): + contact_counts["IT-Leiter"] += len(found_contacts) + elif "geschäftsführer" in position.lower() or "vorstand" in position.lower(): + contact_counts["Geschäftsführer"] += len(found_contacts) + elif "disponent" in position.lower(): + contact_counts["Disponent"] += len(found_contacts) + + # Füge gefundene Kontakte zur Liste hinzu (mit Suchkategorie) + for contact in found_contacts: + contact["Suchbegriffskategorie"] = position + all_found_contacts.append(contact) + + time.sleep(1.5) # Kleine Pause zwischen SerpAPI-Aufrufen + + # Verarbeite gefundene Kontakte und schreibe ins Contacts-Sheet + rows_to_append = [] + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + unique_contacts = {c['LinkedInURL']: c for c in all_found_contacts}.values() # Deduplizieren basierend auf URL + + for contact in unique_contacts: + firstname = contact.get("Vorname", "") + lastname = contact.get("Nachname", "") + gender_value = get_gender(firstname) + email = get_email_address(firstname, lastname, website) + + contact_row = [ + contact.get("Firmenname", ""), + contact.get("CRM Kurzform", ""), + contact.get("Website", ""), + gender_value, + firstname, + lastname, + contact.get("Position", ""), + contact.get("Suchbegriffskategorie", ""), + email, + contact.get("LinkedInURL", ""), + timestamp + ] + rows_to_append.append(contact_row) + + if rows_to_append: + try: + # Verwende append_rows für Effizienz + contacts_sheet.append_rows(rows_to_append, value_input_option='USER_ENTERED') + debug_print(f"Zeile {i}: {len(rows_to_append)} neue Kontakte zum 'Contacts'-Blatt hinzugefügt.") + except Exception as e: + debug_print(f"Zeile {i}: Fehler beim Hinzufügen von Kontakten zum Sheet: {e}") + # Evtl. einzeln versuchen bei Fehler? + + # Aktualisiere Trefferzahlen und Timestamp im Hauptblatt (Batch Update) + main_sheet_updates = [] + main_sheet_updates.append({'range': f'AI{i}', 'values': [[str(contact_counts["Serviceleiter"])]]}) + main_sheet_updates.append({'range': f'AJ{i}', 'values': [[str(contact_counts["IT-Leiter"])]]}) + main_sheet_updates.append({'range': f'AK{i}', 'values': [[str(contact_counts["Geschäftsführer"])]]}) + main_sheet_updates.append({'range': f'AL{i}', 'values': [[str(contact_counts["Disponent"])]]}) + main_sheet_updates.append({'range': f'AM{i}', 'values': [[timestamp]]}) # Contact Search Timestamp + + sheet_handler.batch_update_cells(main_sheet_updates) + debug_print(f"Zeile {i}: Kontaktzahlen im Hauptblatt aktualisiert: {contact_counts} – Timestamp in AM gesetzt.") + + # Pause nach Verarbeitung einer Firma time.sleep(Config.RETRY_DELAY) + debug_print("Contact Research abgeschlossen.") -# ----------------- DataProcessor-Klasse inklusive neuer SERP-API Website Lookup-Methode ----------------- +# ==================== ALIGNMENT DEMO (Hauptblatt) ==================== +def alignment_demo(sheet): + """Schreibt die Header-Struktur (Zeilen 1-5) ins angegebene Sheet.""" + # Definition der Header wie im Original-Code + new_headers = [ + ["ReEval Flag", "CRM Name", "CRM Kurzform", "CRM Website", "CRM Ort", "CRM Beschreibung", "CRM Branche", "CRM Beschreibung Branche extern", "CRM Anzahl Techniker", "CRM Umsatz", "CRM Anzahl Mitarbeiter", "CRM Vorschlag Wiki URL", "Wiki URL", "Wiki Absatz", "Wiki Branche", "Wiki Umsatz", "Wiki Mitarbeiter", "Wiki Kategorien", "Chat Wiki Konsistenzprüfung", "Chat Begründung Wiki Inkonsistenz", "Chat Vorschlag Wiki Artikel", "Begründung bei Abweichung", "Chat Vorschlag Branche", "Chat Konsistenz Branche", "Chat Begründung Abweichung Branche", "Chat Prüfung FSM Relevanz", "Chat Begründung für FSM Relevanz", "Chat Schätzung Anzahl Mitarbeiter", "Chat Konsistenzprüfung Mitarbeiterzahl", "Chat Begründung Abweichung Mitarbeiterzahl", "Chat Einschätzung Anzahl Servicetechniker", "Chat Begründung Abweichung Anzahl Servicetechniker", "Chat Schätzung Umsatz", "Chat Begründung Abweichung Umsatz", "Linked Serviceleiter gefunden", "Linked It-Leiter gefunden", "Linked Management gefunden", "Linked Disponent gefunden", "Contact Search Timestamp", "Wikipedia Timestamp", "Timestamp letzte Prüfung", "Version", "Tokens", "Website Rohtext", "Website Zusammenfassung"], + ["CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "LinkedIn (via SerpApi)", "LinkedIn (via SerpApi)", "LinkedIn (via SerpApi)", "LinkedIn (via SerpApi)", "System", "System", "System", "System", "System", "Web Scraper", "Chat GPT API"], + ["Prozess", "Firmenname", "Firmenname", "Website", "Ort", "Beschreibung (Text)", "Branche", "Branche", "Anzahl Servicetechniker", "Umsatz", "Anzahl Mitarbeiter", "Wikipedia Artikel URL", "Wikipedia Artikel", "Beschreibung (Text)", "Branche", "Umsatz", "Anzahl Mitarbeiter", "Kategorien (Text)", "Verifizierung", "Begründung bei Abweichung", "Wikipedia Artikel", "Wikipedia Artikel", "Branche", "Branche", "Branche", "FSM Relevanz", "FSM Relevanz", "Anzahl Mitarbeiter", "Anzahl Mitarbeiter", "Anzahl Mitarbeiter", "Anzahl Servicetechniker", "Anzahl Servicetechniker", "Umsatz", "Umsatz", "Kontakte zur Firma", "Kontakte zur Firma", "Kontakte zur Firma", "Kontakte zur Firma", "Timestamp", "Timestamp", "Timestamp", "Version des Skripts die verwendet wurde", "ChatGPT Tokens", "Website-Content", "Website Zusammenfassung"], + ["Systemspalte, irrelevant für den Prompt. Wird zur manuellen Neuprüfung genutzt.", "Enthält den Firmennamen; Normalisierung erfolgt bei der Suche.", "Manuell gepflegte Kurzform, meist die ersten 2 Worte.", "Website des Unternehmens.", "Ort des Unternehmens.", "Kurze Beschreibung des Unternehmens.", "Aktuelle Branchenzuweisung gemäß Ziel-Branchenschema.", "Externe Branchenbeschreibung (z.B. von Dealfront).", "Recherchierte Anzahl Servicetechniker.", "Umsatz in Mio. € (CRM).", "Anzahl Mitarbeiter (CRM).", "Vorgeschlagene Wikipedia URL (Ausgangspunkt).", "Wikipedia URL (Ergebnis der Suche).", "Erster Absatz des Wikipedia-Artikels.", "Wikipedia-Branche – für den Branchenabgleich.", "Wikipedia-Umsatz – zur Validierung.", "Wikipedia-Mitarbeiterzahl – zur Validierung.", "Liste der Wikipedia-Kategorien.", "\"OK\" oder \"X\" – Ergebnis der Wikipedia-Validierung.", "Begründung bei Inkonsistenz (Wiki).", "Chat-Vorschlag Wiki Artikel: Falls kein passender Artikel gefunden, alternativ vorschlagen.", "Nicht genutzt, evtl. für zukünftige Funktionen.", "Branchenvorschlag via ChatGPT (alternativer Vorschlag).", "Vergleich: Übereinstimmung CRM vs. ChatGPT-Branche (OK/X).", "Begründung bei abweichender Branchenzuordnung.", "FSM-Relevanz: Bewertung, ob das Unternehmen für FSM geeignet ist (OK/X).", "Begründung zur FSM-Bewertung.", "Schätzung Anzahl Mitarbeiter via ChatGPT (nur falls Wiki-Daten fehlen).", "Vergleich CRM vs. Wiki vs. ChatGPT Mitarbeiterzahl (OK/X).", "Begründung bei Mitarbeiterabweichung (Prozentdifferenz).", "Schätzung Servicetechniker via ChatGPT (in Kategorien, z.B. <50, >100, >200, >500).", "Begründung bei Abweichung der Technikerzahl.", "Schätzung Umsatz via ChatGPT.", "Begründung bei Umsatzabweichung.", "Anzahl Kontakte (Serviceleiter) gefunden.", "Anzahl Kontakte (IT-Leiter) gefunden.", "Anzahl Kontakte (Management) gefunden.", "Anzahl Kontakte (Disponent) gefunden.", "Timestamp der Kontaktsuche.", "Timestamp der Wikipedia-Suche.", "Timestamp der ChatGPT-Bewertung.", "Ausgabe der Skriptversion, die das Ergebnis erzeugt hat.", "Token-Zählung (separat pro Modul).", "Roh extrahierter Text der Firmenwebsite (maximal 1000 Zeichen).", "Zusammenfassung des Webseiteninhalts, fokussiert auf Tätigkeitsfeld, Produkte & Leistungen."], + ["Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Wird durch Wikipedia Scraper bereitgestellt", "Wird zunächst nicht verwendet, kann aber zum Vergleich mit der CRM-Beschreibung genutzt werden.", "Wird u.a. zur finalen Ermittlung der Branche im Ziel-Branchenschema genutzt und mit der CRM-Branche bzw. CRM-Beschreibung Branche Extern verglichen. Stimmen alle drei Einstufungen grob überein, bestärkt dies die ursprüngliche Einstufung. Laufen diese Branchen weit auseinander, soll – sofern der Wikipedia-Artikel verifiziert ist – die Branche von Wikipedia als zuverlässigste Quelle bewertet werden, danach folgen CRM-Beschreibung Branche Extern und CRM-Branche an dritter Stelle.", "Wird u.a. mit CRM-Umsatz zur Validierung des Unternehmens verglichen bzw. zur Bewertung der Größe / Einschätzung der Technikerzahl bzw. FSM-Relevanz genutzt.", "Wird u.a. mit CRM-Anzahl Mitarbeiter zur Validierung des Unternehmens verglichen bzw. zur Bewertung der Größe / Einschätzung der Technikerzahl bzw. FSM-Relevanz genutzt.", "Wenn Website-Daten fehlen, wird in diesem Feld keine zusätzliche Information einbezogen; ansonsten als zusätzlicher Kontext.", "\"Es soll durch ChatGPT geprüft werden, ob anhand der vorliegenden Daten bestätigt werden kann, dass der Wikipedia-Eintrag das Unternehmen sicher beschreibt. Dabei können alle Daten (Website, Umsatz, Mitarbeiterzahl etc.) berücksichtigt werden. Eine gewisse Toleranz (±30%) ist erlaubt. Insbesondere bei Konzernstrukturen muss großzügig bewertet werden. Abweichungen sollen in der Spalte 'Chat Begründung Wiki Inkonsistenz' begründet werden.\"", "\"Liegt eine Inkonsistenz zwischen dem gefundenen Wikipedia-Artikel und dem Unternehmen vor, so soll dies kurz begründet werden. Wurde der Artikel als unpassend identifiziert, soll ChatGPT einen alternativen Wikipedia-Artikel vorschlagen und diesen in 'Chat Vorschlag Wiki Artikel' ausgeben.\"", "\"Sollte durch die Wikipedia-Suche kein Artikel gefunden werden oder als unpassend bewertet werden, soll ChatGPT eigenständig nach einem passenden Artikel recherchieren. Der gefundene Artikel muss vom als unpassend bewerteten Artikel abweichen. Wird kein passender Artikel gefunden, soll 'kein Artikel verfügbar' ausgegeben werden.\"", "XXX derzeit nicht verwendet, wird vermutlich gelöscht xxx", "\"ChatGPT soll anhand der vorliegenden Informationen prüfen, welcher Branche des Ziel-Branchenschemas das Unternehmen am ehesten zugeordnet werden kann. Das Ziel-Branchenschema darf nicht verändert werden, sondern die Vorschläge müssen exakt diesem Schema entsprechen.\"", "Die in Spalte CRM festgelegte Branche soll mit der von ChatGPT ermittelten Branche in 'Chat Vorschlag Branche' verglichen werden.", "Weicht die von ChatGPT ermittelte Branche von der in CRM vorliegenden ab, so soll ChatGPT die Abweichung kurz begründen.", "ChatGPT soll anhand der vorliegenden Daten prüfen, ob das Unternehmen für den Einsatz einer Field Service Management Lösung geeignet ist.", "Die in 'Chat Begründung für FSM Relevanz' angegebene Begründung soll zur Bewertung der FSM-Eignung herangezogen werden.", "Nur wenn kein Wikipedia-Eintrag vorhanden ist, soll ChatGPT basierend auf öffentlich verfügbaren Informationen die Mitarbeiterzahl schätzen. Falls keine Schätzung möglich ist, wird 'keine Schätzung möglich' ausgegeben.", "Entspricht die durch ChatGPT ermittelte Mitarbeiterzahl ungefähr den in CRM und Wikipedia ermittelten Werten (±30%), wird 'OK' ausgegeben, andernfalls 'X' und eine Begründung in 'Chat Begründung Abweichung Mitarbeiterzahl'.", "Weicht die von ChatGPT geschätzte Mitarbeiterzahl signifikant von den CRM- oder Wikipedia-Werten ab, soll dies kurz begründet werden.", "ChatGPT soll auf Basis öffentlich zugänglicher Informationen eine Schätzung der Anzahl Servicetechniker abgeben (Kategorisierung: 0, <50, >100, >200, >500). Bei Abweichungen der Recherche-Werte soll 'X' ausgegeben werden, ansonsten 'OK'.", "Weicht die von ChatGPT geschätzte Technikerzahl von den CRM-Werten ab, soll dies begründet werden.", "Nur wenn kein Wikipedia-Eintrag vorhanden ist, soll ChatGPT den Umsatz anhand der Unternehmenswebsite oder anderer Daten schätzen. Bei fehlender Schätzung soll 'keine Schätzung möglich' ausgegeben werden.", "ChatGPT soll signifikante Umsatzabweichungen zwischen den Schätzungen von Chat, Wikipedia und CRM begründen. Stimmen die Werte (±30%) überein, wird 'OK' ausgegeben.", "Über SerpAPI wird zusammen mit der in 'CRM Kurzform' enthaltenen Information nach 'Serviceleiter' gesucht.", "Über SerpAPI wird zusammen mit 'CRM Kurzform' nach 'Leiter IT' gesucht.", "Über SerpAPI wird zusammen mit 'CRM Kurzform' nach 'Geschäftsführer' gesucht.", "Über SerpAPI wird zusammen mit 'CRM Kurzform' erneut nach 'Serviceleiter' gesucht.", "Wenn die Kontaktsuche gestartet wird, wird der erste Eintrag ohne Zeitstempel gesucht; Zeilen mit vorhandenem Zeitstempel werden übersprungen.", "Wenn die Wikipedia-Suche gestartet wird, wird der erste Eintrag ohne Zeitstempel gesucht; Zeilen mit vorhandenem Zeitstempel werden übersprungen.", "Wenn die ChatGPT-Bewertung gestartet wird, wird der erste Eintrag ohne Zeitstempel gesucht; Zeilen mit vorhandenem Zeitstempel werden übersprungen.", "Wird durch das System befüllt", "Wird durch tiktoken berechnet"] + ] + # Bestimme den Bereich basierend auf der Anzahl der Spalten in der ersten Header-Zeile + num_cols = len(new_headers[0]) + # Konvertiere Spaltenanzahl in Buchstaben (A=1, B=2, ..., Z=26, AA=27, ...) + def colnum_string(n): + string = "" + while n > 0: + n, remainder = divmod(n - 1, 26) + string = chr(65 + remainder) + string + return string + + end_col_letter = colnum_string(num_cols) + header_range = f"A1:{end_col_letter}{len(new_headers)}" + + try: + sheet.update(values=new_headers, range_name=header_range) + print(f"Alignment-Demo abgeschlossen: Header in Bereich {header_range} geschrieben.") + except Exception as e: + print(f"Fehler beim Schreiben der Alignment-Demo Header: {e}") + + +# ==================== DATA PROCESSOR ==================== class DataProcessor: - def __init__(self): - self.sheet_handler = GoogleSheetHandler() - self.wiki_scraper = WikipediaScraper() - - def process_serp_website_lookup(self): - debug_print("Starte SERP-API Website Lookup für alle Zeilen ohne CRM-Website (Spalte D).") - for i, row in enumerate(self.sheet_handler.sheet_values[1:], start=2): - current_website = row[3] if len(row) > 3 else "" - if current_website.strip() == "": - company_name = row[1] if len(row) > 1 else "" + # Diese Klasse enthält jetzt hauptsächlich die Logik für die Verarbeitung einzelner Zeilen + # und spezifische Modi, die nicht als Batch laufen. + def __init__(self, sheet_handler): + self.sheet_handler = sheet_handler + self.wiki_scraper = WikipediaScraper() # Eigene Instanz des Scrapers + + # @retry_on_failure # Vorsicht mit Retry auf dieser Ebene, kann lange dauern + def _process_single_row(self, row_num_in_sheet, row_data, process_wiki=True, process_chatgpt=True, process_website=True): + """Verarbeitet die Daten für eine einzelne Zeile.""" + debug_print(f"--- Starte Verarbeitung für Zeile {row_num_in_sheet} ---") + + # Verwende COLUMN_MAP für sicherere Zugriffe + # Beispiel: company_name = row_data[COLUMN_MAP["CRM Name"]] if len(row_data) > COLUMN_MAP["CRM Name"] else "" + # Der Einfachheit halber bleiben wir vorerst bei Indizes, aber mit Bewusstsein für die Karte + company_name = row_data[1] if len(row_data) > 1 else "" + website_url = row_data[3] if len(row_data) > 3 else "" + crm_kurzform = row_data[2] if len(row_data) > 2 else company_name # Fallback für Kurzform + + # --- 1. Website Handling (Lookup, Scrape, Summarize) --- + original_website = website_url + website_raw = "k.A." + website_summary = "k.A." + website_details = "k.A." + + # Website Lookup, wenn leer + if process_website and (not website_url or website_url.strip().lower() == "k.a."): + debug_print(f"Zeile {row_num_in_sheet}: CRM Website fehlt, starte SERP Lookup für '{company_name}'...") + new_website = serp_website_lookup(company_name) + if new_website != "k.A.": + website_url = new_website + debug_print(f"Zeile {row_num_in_sheet}: SERP Lookup erfolgreich: {website_url}") + # Schreibe neue Website direkt zurück (optional, oder sammle für Batch Update) + # self.sheet_handler.sheet.update_cell(row_num_in_sheet, COLUMN_MAP["CRM Website"] + 1, website_url) + else: + debug_print(f"Zeile {row_num_in_sheet}: SERP Lookup erfolglos.") + + # Website Scraping (Rohtext, Zusammenfassung, Details), wenn URL vorhanden + if process_website and website_url and website_url.strip().lower() != "k.a.": + debug_print(f"Zeile {row_num_in_sheet}: Starte Website Scraping für {website_url}...") + website_raw = get_website_raw(website_url) + website_summary = summarize_website_content(website_raw) # Benötigt OpenAI Key + # Website Details (optional, kann viele Tokens kosten) + # website_details = scrape_website_details(website_url) + # debug_print(f"Zeile {row_num_in_sheet}: Website Details: {website_details[:100]}...") + elif process_website: + debug_print(f"Zeile {row_num_in_sheet}: Überspringe Website Scraping (keine gültige URL).") + + + # --- 2. Wikipedia Handling --- + wiki_data = { # Standardwerte + 'url': 'k.A.', 'first_paragraph': 'k.A.', 'branche': 'k.A.', + 'umsatz': 'k.A.', 'mitarbeiter': 'k.A.', 'categories': 'k.A.' + } + wiki_timestamp_needed = len(row_data) <= COLUMN_MAP["Wikipedia Timestamp"] or not row_data[COLUMN_MAP["Wikipedia Timestamp"]].strip() + + if process_wiki and wiki_timestamp_needed: + debug_print(f"Zeile {row_num_in_sheet}: Starte Wikipedia Verarbeitung...") + # Prüfe, ob CRM einen Vorschlag hat + crm_wiki_url = row_data[COLUMN_MAP["CRM Vorschlag Wiki URL"]] if len(row_data) > COLUMN_MAP["CRM Vorschlag Wiki URL"] and row_data[COLUMN_MAP["CRM Vorschlag Wiki URL"]].strip() not in ["", "k.A."] else None + + article_page = None + if crm_wiki_url: + debug_print(f"Zeile {row_num_in_sheet}: Prüfe CRM Wiki Vorschlag: {crm_wiki_url}") + page = self.wiki_scraper._fetch_page_content(crm_wiki_url.split('/')[-1]) + if page and self.wiki_scraper._validate_article(page, company_name, website_url): + article_page = page + else: + debug_print(f"Zeile {row_num_in_sheet}: CRM Wiki Vorschlag nicht validiert. Starte Suche...") + article_page = self.wiki_scraper.search_company_article(company_name, website_url) + else: + debug_print(f"Zeile {row_num_in_sheet}: Kein CRM Wiki Vorschlag. Starte Suche...") + article_page = self.wiki_scraper.search_company_article(company_name, website_url) + + if article_page: + debug_print(f"Zeile {row_num_in_sheet}: Extrahiere Daten aus Artikel: {article_page.url}") + wiki_data = self.wiki_scraper.extract_company_data(article_page.url) + else: + debug_print(f"Zeile {row_num_in_sheet}: Kein passender Wikipedia Artikel gefunden.") + wiki_data['url'] = 'Kein Artikel gefunden' # Spezifische Kennung + elif process_wiki: + debug_print(f"Zeile {row_num_in_sheet}: Überspringe Wikipedia Verarbeitung (Timestamp AN vorhanden).") + # Lade vorhandene Wiki-Daten aus der Zeile, um sie für ChatGPT verfügbar zu machen + wiki_data['url'] = row_data[COLUMN_MAP["Wiki URL"]] if len(row_data) > COLUMN_MAP["Wiki URL"] else 'k.A.' + wiki_data['first_paragraph'] = row_data[COLUMN_MAP["Wiki Absatz"]] if len(row_data) > COLUMN_MAP["Wiki Absatz"] else 'k.A.' + wiki_data['branche'] = row_data[COLUMN_MAP["Wiki Branche"]] if len(row_data) > COLUMN_MAP["Wiki Branche"] else 'k.A.' + wiki_data['umsatz'] = row_data[COLUMN_MAP["Wiki Umsatz"]] if len(row_data) > COLUMN_MAP["Wiki Umsatz"] else 'k.A.' + wiki_data['mitarbeiter'] = row_data[COLUMN_MAP["Wiki Mitarbeiter"]] if len(row_data) > COLUMN_MAP["Wiki Mitarbeiter"] else 'k.A.' + wiki_data['categories'] = row_data[COLUMN_MAP["Wiki Kategorien"]] if len(row_data) > COLUMN_MAP["Wiki Kategorien"] else 'k.A.' + + + # --- 3. ChatGPT Evaluationen --- + chat_results = {} # Sammle Ergebnisse der einzelnen ChatGPT Calls + chat_timestamp_needed = len(row_data) <= COLUMN_MAP["Timestamp letzte Prüfung"] or not row_data[COLUMN_MAP["Timestamp letzte Prüfung"]].strip() + + if process_chatgpt and chat_timestamp_needed: + debug_print(f"Zeile {row_num_in_sheet}: Starte ChatGPT Evaluationen...") + + # 3.1 Branchenevaluierung (Wichtigste zuerst?) + crm_branche = row_data[COLUMN_MAP["CRM Branche"]] if len(row_data) > COLUMN_MAP["CRM Branche"] else "" + crm_beschreibung = row_data[COLUMN_MAP["CRM Beschreibung"]] if len(row_data) > COLUMN_MAP["CRM Beschreibung"] else "" + chat_results['branche'] = evaluate_branche_chatgpt(crm_branche, crm_beschreibung, wiki_data['branche'], wiki_data['categories'], website_summary) + + # 3.2 Weitere Evaluationen (Beispiele, ggf. anpassen/implementieren) + # chat_results['wiki_verification'] = process_wiki_verification(row_data, wiki_data) # Siehe Batch-Mode + # chat_results['fsm'] = evaluate_fsm_suitability(company_name, wiki_data) + # chat_results['st_estimate'] = evaluate_servicetechnicians_estimate(company_name, wiki_data) + # crm_techniker = row_data[COLUMN_MAP["CRM Anzahl Techniker"]] if len(row_data) > COLUMN_MAP["CRM Anzahl Techniker"] else "k.A." + # internal_category = map_internal_technicians(crm_techniker) + # chat_results['st_explanation'] = evaluate_servicetechnicians_explanation(company_name, chat_results.get('st_estimate'), wiki_data) if internal_category != chat_results.get('st_estimate') else "ok" + # crm_mitarbeiter = row_data[COLUMN_MAP["CRM Anzahl Mitarbeiter"]] if len(row_data) > COLUMN_MAP["CRM Anzahl Mitarbeiter"] else "k.A." + # chat_results['emp_estimate'] = process_employee_estimation(company_name, wiki_data['first_paragraph'], crm_mitarbeiter) + # chat_results['emp_consistency'] = process_employee_consistency(crm_mitarbeiter, wiki_data['mitarbeiter'], chat_results.get('emp_estimate')) + # crm_umsatz = row_data[COLUMN_MAP["CRM Umsatz"]] if len(row_data) > COLUMN_MAP["CRM Umsatz"] else "k.A." + # chat_results['umsatz_estimate'] = evaluate_umsatz_chatgpt(company_name, wiki_data['umsatz']) + # TODO: Vergleich Umsatz / Begründung Abweichung + + # 3.x Token Zählung (Beispielhaft, genauer pro Call) + # total_tokens = token_count(str(wiki_data)) + token_count(str(chat_results)) # Grobe Schätzung + # chat_results['tokens'] = total_tokens + + elif process_chatgpt: + debug_print(f"Zeile {row_num_in_sheet}: Überspringe ChatGPT Evaluationen (Timestamp AO vorhanden).") + + # --- 4. Daten für Batch Update sammeln --- + updates = [] + current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + # 4.1 Website-Daten + if process_website: + if website_url != original_website: # Nur wenn URL sich geändert hat (durch Lookup) + updates.append({'range': f'D{row_num_in_sheet}', 'values': [[website_url]]}) + updates.append({'range': f'AR{row_num_in_sheet}', 'values': [[website_raw]]}) + updates.append({'range': f'AS{row_num_in_sheet}', 'values': [[website_summary]]}) + # Optional: Details schreiben + # updates.append({'range': f'??{row_num_in_sheet}', 'values': [[website_details]]}) # Braucht neue Spalte + + # 4.2 Wikipedia-Daten (nur wenn neu verarbeitet) + if process_wiki and wiki_timestamp_needed: + updates.append({'range': f'M{row_num_in_sheet}', 'values': [[wiki_data.get('url', 'k.A.')]]}) + updates.append({'range': f'N{row_num_in_sheet}', 'values': [[wiki_data.get('first_paragraph', 'k.A.')]]}) + updates.append({'range': f'O{row_num_in_sheet}', 'values': [[wiki_data.get('branche', 'k.A.')]]}) + updates.append({'range': f'P{row_num_in_sheet}', 'values': [[wiki_data.get('umsatz', 'k.A.')]]}) + updates.append({'range': f'Q{row_num_in_sheet}', 'values': [[wiki_data.get('mitarbeiter', 'k.A.')]]}) + updates.append({'range': f'R{row_num_in_sheet}', 'values': [[wiki_data.get('categories', 'k.A.')]]}) + updates.append({'range': f'AN{row_num_in_sheet}', 'values': [[current_timestamp]]}) # Wiki Timestamp + + # 4.3 ChatGPT-Daten (nur wenn neu verarbeitet) + if process_chatgpt and chat_timestamp_needed: + # Branche + updates.append({'range': f'W{row_num_in_sheet}', 'values': [[chat_results.get('branche', {}).get('branch', 'k.A.')]]}) + updates.append({'range': f'X{row_num_in_sheet}', 'values': [[chat_results.get('branche', {}).get('consistency', 'k.A.')]]}) + updates.append({'range': f'Y{row_num_in_sheet}', 'values': [[chat_results.get('branche', {}).get('justification', 'k.A.')]]}) + # Weitere ChatGPT Ergebnisse hier einfügen... + # updates.append({'range': f'Z{row_num_in_sheet}', 'values': [[chat_results.get('fsm', {}).get('suitability', 'k.A.')]]}) + # updates.append({'range': f'AA{row_num_in_sheet}', 'values': [[chat_results.get('fsm', {}).get('justification', 'k.A.')]]}) + # ... etc. ... + # Tokens + # updates.append({'range': f'AQ{row_num_in_sheet}', 'values': [[str(chat_results.get('tokens', 0))]]}) + # Timestamp für ChatGPT/Letzte Prüfung + updates.append({'range': f'AO{row_num_in_sheet}', 'values': [[current_timestamp]]}) + + # 4.4 Immer Update: Version + updates.append({'range': f'AP{row_num_in_sheet}', 'values': [[Config.VERSION]]}) + + + # --- 5. Batch Update durchführen --- + if updates: + self.sheet_handler.batch_update_cells(updates) + debug_print(f"Zeile {row_num_in_sheet}: Batch-Update erfolgreich.") + else: + debug_print(f"Zeile {row_num_in_sheet}: Keine Updates zum Schreiben.") + + debug_print(f"--- Verarbeitung für Zeile {row_num_in_sheet} abgeschlossen ---") + # Kurze Pause nach jeder Zeile + time.sleep(max(0.5, Config.RETRY_DELAY / 2)) # Kürzere Pause bei Einzelverarbeitung + + + def process_rows_sequentially(self, start_row_index, num_rows_to_process, process_wiki=True, process_chatgpt=True, process_website=True): + """ Verarbeitet Zeilen sequentiell ab einem Startindex. """ + data_rows = self.sheet_handler.get_data() # Daten ohne Header + header_rows = 5 + + end_row_index = min(start_row_index + num_rows_to_process, len(data_rows)) + + if start_row_index >= len(data_rows): + debug_print("Startindex liegt hinter der letzten Datenzeile. Keine Verarbeitung.") + return + + debug_print(f"Verarbeite {end_row_index - start_row_index} Zeilen sequentiell (Index {start_row_index} bis {end_row_index - 1})...") + + for i in range(start_row_index, end_row_index): + row_data = data_rows[i] + row_num_in_sheet = i + header_rows + 1 # 1-basierter Sheet-Index + + # Überspringe Zeilen vor Zeile 7 generell? Nein, start_row_index sollte das regeln. + # if row_num_in_sheet < 7: continue + + self._process_single_row(row_num_in_sheet, row_data, process_wiki, process_chatgpt, process_website) + + + def process_reevaluation_rows(self): + """ Verarbeitet nur Zeilen, die in Spalte A mit 'x' markiert sind. """ + debug_print("Starte Re-Evaluierungsmodus (Spalte A = 'x')...") + data_rows = self.sheet_handler.get_data() + header_rows = 5 + rows_processed = 0 + for i, row in enumerate(data_rows): + row_num_in_sheet = i + header_rows + 1 + # Prüfe Flag in Spalte A (Index 0) + if len(row) > COLUMN_MAP["ReEval Flag"] and row[COLUMN_MAP["ReEval Flag"]].strip().lower() == "x": + debug_print(f"Re-Evaluiere Zeile {row_num_in_sheet}...") + # Führe volle Verarbeitung für diese Zeile durch + self._process_single_row(row_num_in_sheet, row, process_wiki=True, process_chatgpt=True, process_website=True) + rows_processed += 1 + # Optional: Flag nach Verarbeitung löschen? + # self.sheet_handler.sheet.update_cell(row_num_in_sheet, COLUMN_MAP["ReEval Flag"] + 1, "") + debug_print(f"Re-Evaluierung abgeschlossen. {rows_processed} Zeilen verarbeitet.") + + + def process_website_details_for_marked_rows(self): + """ Neuer Modus 23: Extrahiert Website-Details für markierte Zeilen. """ + debug_print("Starte Modus 23: Website Detail Extraction für Zeilen mit 'x' in Spalte A.") + data_rows = self.sheet_handler.get_data() + header_rows = 5 + rows_processed = 0 + + for i, row in enumerate(data_rows): + row_num_in_sheet = i + header_rows + 1 + # Prüfe Flag in Spalte A (Index 0) + if len(row) > COLUMN_MAP["ReEval Flag"] and row[COLUMN_MAP["ReEval Flag"]].strip().lower() == "x": + website_url = row[COLUMN_MAP["CRM Website"]] if len(row) > COLUMN_MAP["CRM Website"] else "" + if not website_url or website_url.strip().lower() == "k.a.": + debug_print(f"Zeile {row_num_in_sheet}: Keine gültige Website in Spalte D vorhanden, überspringe.") + continue + + debug_print(f"Zeile {row_num_in_sheet}: Extrahiere Website Details von {website_url}...") + details = scrape_website_details(website_url) + + # Speichere das Detail-Ergebnis in Spalte AR (Index 43) + update_data = [{'range': f'AR{row_num_in_sheet}', 'values': [[details]]}] + self.sheet_handler.batch_update_cells(update_data) + debug_print(f"Zeile {row_num_in_sheet}: Website Detail Extraction abgeschlossen, Ergebnis in Spalte AR geschrieben.") + rows_processed += 1 + time.sleep(Config.RETRY_DELAY) + + debug_print(f"Modus 23 abgeschlossen. {rows_processed} Zeilen verarbeitet.") + + + def process_serp_website_lookup_for_empty(self): + """ Neuer Modus 22: Füllt fehlende Websites via SERP API. """ + debug_print("Starte Modus 22: SERP API Website Lookup für leere Zellen in Spalte D.") + data_rows = self.sheet_handler.get_data() + header_rows = 5 + rows_processed = 0 + + for i, row in enumerate(data_rows): + row_num_in_sheet = i + header_rows + 1 + current_website = row[COLUMN_MAP["CRM Website"]] if len(row) > COLUMN_MAP["CRM Website"] else "" + + if not current_website or current_website.strip().lower() == "k.a.": + company_name = row[COLUMN_MAP["CRM Name"]] if len(row) > COLUMN_MAP["CRM Name"] else "" + if not company_name: + debug_print(f"Zeile {row_num_in_sheet}: Übersprungen (kein Firmenname für Lookup).") + continue + + debug_print(f"Zeile {row_num_in_sheet}: Suche Website für '{company_name}'...") new_website = serp_website_lookup(company_name) if new_website != "k.A.": - self.sheet_handler.sheet.update(values=[[new_website]], range_name=f"D{i}") - debug_print(f"Zeile {i}: Neue Website gefunden und in Spalte D eingetragen: {new_website}") + update_data = [{'range': f'D{row_num_in_sheet}', 'values': [[new_website]]}] + self.sheet_handler.batch_update_cells(update_data) + debug_print(f"Zeile {row_num_in_sheet}: Neue Website '{new_website}' gefunden und in Spalte D eingetragen.") + rows_processed += 1 else: - debug_print(f"Zeile {i}: Keine Website gefunden für {company_name}.") - time.sleep(Config.RETRY_DELAY) - else: - debug_print(f"Zeile {i}: CRM-Website bereits vorhanden, überspringe.") + debug_print(f"Zeile {row_num_in_sheet}: Keine Website gefunden.") + + time.sleep(Config.RETRY_DELAY) # Pause nach jedem Lookup + + debug_print(f"Modus 22 abgeschlossen. {rows_processed} Websites ergänzt.") - def process_website_details(self): - """ - Neuer Modus 23: - Für alle Zeilen, in denen das Re-Evaluation-Flag in Spalte A "x" steht - und ein gültiger Website-URL in Spalte D vorhanden ist, wird die Funktion - scrape_website_details(url) aufgerufen. Das kombinierte Ergebnis (Title, Meta-Description, - h1, h2, h3) wird in Spalte AR geschrieben. - """ - debug_print("Starte Modus 23: Website Detail Extraction für Zeilen mit 'x' in Spalte A.") - for i, row in enumerate(self.sheet_handler.sheet_values[1:], start=2): - # Hier verarbeiten wir nur die Zeilen, die gezielt zur Re-Evaluation markiert sind: - if row[0].strip().lower() != "x": - continue - website_url = row[3] if len(row) > 3 else "" - if website_url.strip() == "" or website_url.strip().lower() == "k.a.": - debug_print(f"Zeile {i}: Keine gültige Website in Spalte D vorhanden, überspringe.") - continue - details = scrape_website_details(website_url) - # Speichere das Detail-Ergebnis in Spalte AR (Website Rohtext) - self.sheet_handler.sheet.update(values=[[details]], range_name=f"AR{i}") - debug_print(f"Zeile {i}: Website Detail Extraction abgeschlossen, Ergebnis in Spalte AR geschrieben.") - time.sleep(Config.RETRY_DELAY) - def process_rows(self, num_rows=None): - global MODE - if MODE == "1": - # Vollständige Verarbeitung (alle Funktionen) - self.process_rows_complete() # Falls diese Methode bereits implementiert ist. - elif MODE == "11": - # Re-Evaluation markierter Zeilen (nur "x" in Spalte A) - for i, row in enumerate(self.sheet_handler.sheet_values[1:], start=2): - if row[0].strip().lower() == "x": - self._process_single_row(i, row) - elif MODE == "21": - # Website-Scraping Testmodus: Nur Website-Rohtext & Zusammenfassung extrahieren - for i, row in enumerate(self.sheet_handler.sheet_values[1:], start=2): - self._process_single_row(i, row, process_wiki=False, process_chatgpt=False) - elif MODE == "22": - # SERP-API Website Lookup: Füllt Spalte D, falls leer - self.process_serp_website_lookup() - elif MODE == "23": - # Neuer Modus 23: Detaillierte Website-Auswertung (nur für Zeilen mit "x" in Spalte A) - self.process_website_details() - elif MODE == "31": - # Nur ChatGPT-Auswertung: Alle ChatGPT-Routinen (ohne Wikipedia und Website) - for i, row in enumerate(self.sheet_handler.sheet_values[1:], start=2): - self._process_single_row(i, row, process_wiki=False, process_chatgpt=True) - elif MODE == "41": - # Nur Wikipedia-Scraping - for i, row in enumerate(self.sheet_handler.sheet_values[1:], start=2): - self._process_single_row(i, row, process_wiki=True, process_chatgpt=False) - elif MODE == "51": - process_verification_only() - elif MODE == "6": - process_contact_research() - elif MODE == "8": - process_batch_token_count() - else: - # Falls ein unbekannter Modus gewählt wird - start_index = self.sheet_handler.get_start_index() - print(f"Starte bei Zeile {start_index+1}") - rows_processed = 0 - for i, row in enumerate(self.sheet_handler.sheet_values[1:], start=2): - if i < start_index: - continue - if num_rows is not None and rows_processed >= num_rows: - break - self._process_single_row(i, row) - rows_processed += 1 - -# ----------------- Main-Funktion ----------------- +# ==================== MAIN FUNCTION ==================== def main(): - global MODE, LOG_FILE - # Argumentparser initialisieren - parser = argparse.ArgumentParser(description="Brancheneinstufung Skript") - parser.add_argument("--mode", type=str, help="Betriebsmodus: wiki, website, branch, combined, etc.") - parser.add_argument("--row_limit", type=int, help="Anzahl der zu verarbeitenden Zeilen/Accounts", default=None) + global MODE, LOG_FILE # MODE wird nicht mehr global benötigt, aber LOG_FILE schon + + # --- Initialisierung --- + # Argument Parser + parser = argparse.ArgumentParser(description="Firmen-Datenanreicherungs-Skript") + parser.add_argument("--mode", type=str, help="Betriebsmodus (z.B. combined, wiki, website, branch, reeval, website_lookup, website_details, contacts, full_run)") + parser.add_argument("--limit", type=int, help="Maximale Anzahl zu verarbeitender Zeilen (für Batch/sequentielle Modi)", default=None) args = parser.parse_args() - # Betriebsmodus aus Kommandozeile oder interaktiv ermitteln - if args.mode: - MODE = args.mode.strip().lower() - print(f"Betriebsmodus (aus Kommandozeile): {MODE}") + # Lade API Keys + Config.load_api_keys() + + # Betriebsmodus ermitteln + valid_modes = ["combined", "wiki", "website", "branch", "reeval", "website_lookup", "website_details", "contacts", "full_run"] + mode = None + if args.mode and args.mode.lower() in valid_modes: + mode = args.mode.lower() + print(f"Betriebsmodus (aus Kommandozeile): {mode}") else: print("Bitte wählen Sie den Betriebsmodus:") - print("wiki: Nur Wikipedia-Verifizierung (Batch)") - print("website: Nur Website-Scraping (Batch)") - print("branch: Nur Brancheneinschätzung (Batch)") - print("combined: Alle Funktionen (Wikipedia, Website, Branch) in einem Durchlauf") - print("1: Vollständige Verarbeitung (alle Funktionen)") - print("11: Re-Evaluation markierter Zeilen (nur 'x' in Spalte A)") - print("21: Website-Scraping Testmodus (nur Website-Rohtext & Zusammenfassung)") - print("22: SERP-API Website Lookup (nur Website-Daten ermitteln)") - print("23: Website Detail Extraction (nur für Zeilen mit 'x')") - print("31: Nur ChatGPT-Auswertung (alle ChatGPT-Routinen)") - print("41: Nur Wikipedia-Scraping") - print("6: Contact Research (LinkedIn)") - print("8: Batch Token-Zählung") - MODE = input("Geben Sie den Modus ein (z.B. wiki, website, branch, combined oder alte Zahl): ").strip().lower() - if not MODE: - MODE = "combined" + print(" combined: Wiki-Verifizierung, Website-Scraping & Branch-Einschätzung (Batch, ab erster leerer Zeile)") + print(" wiki: Nur Wikipedia-Verifizierung (Batch, ab erster leerer Zeile)") + print(" website: Nur Website-Scraping & Zusammenfassung (Batch, ab erster leerer Zeile)") + print(" branch: Nur Branchen-Einschätzung (Batch, ab erster leerer Zeile)") + print(" reeval: Verarbeitet alle Zeilen mit 'x' in Spalte A (volle Verarbeitung)") + print(" website_lookup: Sucht fehlende Websites (Spalte D) via SERP API") + print(" website_details:Extrahiert Title/Desc/H-Tags für Zeilen mit 'x' in Spalte A") + print(" contacts: Sucht LinkedIn Kontakte via SERP API und schreibt in 'Contacts' Blatt") + print(" full_run: Verarbeitet alle Zeilen sequentiell ab der ersten ohne Zeitstempel (AO)") + # print(" 8: Batch Token-Zählung (Platzhalter)") # Modus 8 entfernt/umbenannt? + mode_input = input(f"Geben Sie den Modus ein ({', '.join(valid_modes)}): ").strip().lower() + if mode_input in valid_modes: + mode = mode_input + else: + print("Ungültige Eingabe. Standardmodus 'combined' wird verwendet.") + mode = "combined" # Standardmodus - LOG_FILE = create_log_filename(MODE) - debug_print(f"Start Betriebsmodus {MODE}") + # Zeilenlimit ermitteln + row_limit = args.limit + if row_limit is None and mode in ["combined", "wiki", "website", "branch", "full_run"]: + try: + limit_input = input("Wie viele Zeilen sollen maximal bearbeitet werden? (Enter für alle) ") + if limit_input.strip(): + row_limit = int(limit_input) + print(f"Zeilenlimit: {row_limit}") + else: + row_limit = None # Alle verarbeiten + print("Kein Zeilenlimit gesetzt.") + except ValueError: + print("Ungültige Eingabe für Zeilenlimit. Es werden alle Zeilen verarbeitet.") + row_limit = None + elif row_limit is not None: + print(f"Zeilenlimit (aus Kommandozeile): {row_limit}") - for entry in prompt_overview()[1:]: - debug_print(f"{entry[0]}: {entry[1]}") - dp = DataProcessor() + # Logfile initialisieren + LOG_FILE = create_log_filename(mode) + debug_print(f"===== Skript gestartet =====") + debug_print(f"Version: {Config.VERSION}") + debug_print(f"Betriebsmodus: {mode}") + debug_print(f"Zeilenlimit: {row_limit if row_limit is not None else 'Unbegrenzt'}") + debug_print(f"Logdatei: {LOG_FILE}") - # Row_limit aus Kommandozeile oder interaktiv ermitteln - if args.row_limit is not None: - row_limit = args.row_limit - print(f"Zeilenlimit (aus Kommandozeile): {row_limit}") - else: - try: - row_limit = int(input("Wie viele Zeilen sollen insgesamt bearbeitet werden? ")) - except Exception as e: - debug_print(f"Fehler bei der Eingabe der Zeilenanzahl: {e}. Es werden alle Zeilen verarbeitet.") - row_limit = None + # --- Vorbereitung --- + # Lade Branchenschema + load_target_schema() - # Auswahl des Arbeitsmodus - if MODE in ["wiki", "website", "branch", "combined"]: - run_dispatcher(MODE, row_limit) - elif MODE == "1": - dp.process_rows() - elif MODE == "11": - for i, row in enumerate(dp.sheet_handler.sheet_values[1:], start=2): - if row[0].strip().lower() == "x": - dp._process_single_row(i, row) - elif MODE == "21": - for i, row in enumerate(dp.sheet_handler.sheet_values[1:], start=2): - dp._process_single_row(i, row, process_wiki=False, process_chatgpt=False) - elif MODE == "22": - dp.process_serp_website_lookup() - elif MODE == "23": - dp.process_website_details() - elif MODE == "31": - for i, row in enumerate(dp.sheet_handler.sheet_values[1:], start=2): - dp._process_single_row(i, row, process_wiki=False, process_chatgpt=True) - elif MODE == "41": - for i, row in enumerate(dp.sheet_handler.sheet_values[1:], start=2): - dp._process_single_row(i, row, process_wiki=True, process_chatgpt=False) - elif MODE == "6": - process_contact_research() - elif MODE == "8": - process_batch_token_count() - else: - start_index = dp.sheet_handler.get_start_index() - print(f"Starte bei Zeile {start_index+1}") - rows_processed = 0 - for i, row in enumerate(dp.sheet_handler.sheet_values[1:], start=2): - if i < start_index: - continue - if rows_processed >= 1: - break - dp._process_single_row(i, row) - rows_processed += 1 + # Initialisiere Google Sheet Handler + try: + sheet_handler = GoogleSheetHandler() + except Exception as e: + debug_print(f"FATAL: Konnte Google Sheet Handler nicht initialisieren: {e}") + return # Abbruch + # Initialisiere DataProcessor + data_processor = DataProcessor(sheet_handler) + + # Optional: Alignment Demo für Hauptblatt ausführen? + # run_alignment = input("Sollen die Header im Hauptblatt aktualisiert werden (Alignment Demo)? (j/N): ").lower() + # if run_alignment == 'j': + # alignment_demo(sheet_handler.sheet) + + # --- Modusausführung --- + start_time = time.time() + debug_print(f"Starte Verarbeitung um {datetime.now().strftime('%H:%M:%S')}...") + + try: + if mode in ["wiki", "website", "branch", "combined"]: + run_dispatcher(mode, sheet_handler, row_limit) + elif mode == "reeval": + data_processor.process_reevaluation_rows() + elif mode == "website_lookup": + data_processor.process_serp_website_lookup_for_empty() + elif mode == "website_details": + data_processor.process_website_details_for_marked_rows() + elif mode == "contacts": + process_contact_research(sheet_handler) # Übergib den Handler + elif mode == "full_run": + start_index = sheet_handler.get_start_row_index() # Index in Datenliste + num_to_process = row_limit if row_limit is not None else len(sheet_handler.get_data()) - start_index + if num_to_process > 0: + data_processor.process_rows_sequentially(start_index, num_to_process, process_wiki=True, process_chatgpt=True, process_website=True) + else: + debug_print("Keine Zeilen für 'full_run' zu verarbeiten.") + # elif mode == "8": # Token Count - was soll hier passieren? + # debug_print("Modus 8 (Token Count) ist derzeit nicht implementiert.") + else: + debug_print(f"Unbekannter Modus '{mode}' - keine Aktion ausgeführt.") + + except Exception as e: + debug_print(f"FATAL: Unerwarteter Fehler auf oberster Ebene: {e}") + import traceback + debug_print(traceback.format_exc()) # Detaillierten Stacktrace loggen + + # --- Abschluss --- + end_time = time.time() + duration = end_time - start_time + debug_print(f"Verarbeitung abgeschlossen um {datetime.now().strftime('%H:%M:%S')}.") + debug_print(f"Gesamtdauer: {duration:.2f} Sekunden.") + debug_print(f"===== Skript beendet =====") print(f"Verarbeitung abgeschlossen. Logfile: {LOG_FILE}") + if __name__ == '__main__': main() \ No newline at end of file