diff --git a/brancheneinstufung.py b/brancheneinstufung.py index 40ab1fa3..c2cf7e0d 100644 --- a/brancheneinstufung.py +++ b/brancheneinstufung.py @@ -951,115 +951,203 @@ def token_count(text): class GoogleSheetHandler: def __init__(self): - # ... (init und _connect unverändert) ... + """Initialisiert den Handler, verbindet und lädt initiale Daten.""" self.sheet = None self.sheet_values = [] - self.headers = [] - try: self._connect(); - except Exception as e: raise ConnectionError(f"Google Sheet Handler Init failed: {e}") - if self.sheet: self.load_data() # Lade Daten initial - + self.headers = [] # Speichert die erste Zeile als Header-Namen + try: + self._connect() + if self.sheet: + self.load_data() # Erste Datenladung bei Initialisierung + except Exception as e: + debug_print(f"FATAL: Fehler bei Initialisierung von GoogleSheetHandler: {e}") + # Wirft einen Fehler, damit das Hauptprogramm weiß, dass es nicht weitergehen kann + raise ConnectionError(f"Google Sheet Handler Init failed: {e}") + # retry_on_failure Decorator sollte hier angewendet werden @retry_on_failure def _connect(self): - # ... (unverändert) ... - self.sheet = None; debug_print("Verbinde mit Google Sheets...") + """Stellt Verbindung zum Google Sheet her.""" + self.sheet = None # Sicherstellen, dass sheet vor try None ist + debug_print("Verbinde mit Google Sheets...") try: scope = ["https://www.googleapis.com/auth/spreadsheets"] 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.") - except Exception as e: debug_print(f"FEHLER Connect: {e}"); raise e + gc = gspread.authorize(creds) + sh = gc.open_by_url(Config.SHEET_URL) + self.sheet = sh.sheet1 # Greift auf das erste Blatt zu + debug_print("Verbindung zu Google Sheets erfolgreich.") + except gspread.exceptions.APIError as e: + # Logge spezifische API-Fehler von Google + debug_print(f"FEHLER bei Google API Verbindung: Status {e.response.status_code} - {e.response.text[:200]}") + raise e # Fehler weitergeben, damit retry greift + except Exception as e: + # Logge andere Verbindungsfehler + debug_print(f"FEHLER bei der Google Sheets Verbindung: {type(e).__name__} - {e}") + raise e # Fehler weitergeben + # retry_on_failure Decorator sollte hier angewendet werden @retry_on_failure def load_data(self): - # ... (unverändert) ... - if not self.sheet: return False + """Lädt alle Daten aus dem Sheet und aktualisiert self.sheet_values und self.headers.""" + if not self.sheet: + debug_print("Fehler: Keine Sheet-Verbindung zum Laden der Daten.") + self.sheet_values = [] + self.headers = [] + return False # Signalisiert Fehler debug_print("Lade Daten aus Google Sheet...") try: - self.sheet_values = self.sheet.get_all_values() - if not self.sheet_values: self.headers=[]; return True - if len(self.sheet_values) >= 1: self.headers = self.sheet_values[0] - else: self.headers = [] + self.sheet_values = self.sheet.get_all_values() # Daten neu holen + if not self.sheet_values: + debug_print("Warnung: Google Sheet scheint leer zu sein oder keine Daten zurückgegeben.") + self.headers = [] + elif len(self.sheet_values) >= 1: + self.headers = self.sheet_values[0] # Speichere die erste Zeile als Header + else: + self.headers = [] # Sollte nicht passieren, wenn sheet_values nicht leer war + debug_print(f"Daten neu geladen: {len(self.sheet_values)} Zeilen insgesamt.") - return True - except Exception as e: debug_print(f"FEHLER Laden: {e}"); raise e + return True # Signalisiert Erfolg + except gspread.exceptions.APIError as e: + debug_print(f"Google API Fehler beim Laden der Sheet Daten: Status {e.response.status_code} - {e.response.text[:200]}") + raise e # Damit retry greift + except Exception as e: + debug_print(f"Allgemeiner Fehler beim Laden der Google Sheet Daten: {e}") + raise e # Damit retry greift def get_data(self): - # ... (unverändert) ... - header_rows = 5 - if not self.sheet_values or len(self.sheet_values) <= header_rows: return [] + """Gibt die aktuell im Handler gespeicherten Daten zurück (ohne die ersten 5 Header-Zeilen).""" + header_rows = 5 # Definiert die Anzahl der zu überspringenden Header-Zeilen + if not self.sheet_values or len(self.sheet_values) <= header_rows: + if self.sheet_values: # Logge nur, wenn Daten da, aber zu wenige + debug_print(f"Warnung in get_data: Nur {len(self.sheet_values)} Zeilen vorhanden, weniger als {header_rows} Header-Zeilen erwartet.") + return [] + # Gibt eine Slice der Liste zurück, die die Datenzeilen enthält return self.sheet_values[header_rows:] def get_all_data_with_headers(self): - # ... (unverändert) ... - if not self.sheet_values: return [] + """Gibt alle aktuell im Handler gespeicherten Daten inklusive Header zurück.""" + if not self.sheet_values: + debug_print("Warnung in get_all_data_with_headers: Keine Daten im Handler gespeichert.") + # Optional: Versuche neu zu laden? Oder einfach leere Liste zurückgeben? + # self.load_data() # Erneuter Ladeversuch + # return self.sheet_values + return [] # Gib leere Liste zurück, wenn nichts geladen ist return self.sheet_values def _get_col_letter(self, col_idx_1_based): - # ... (unverändert) ... - string = ""; n = col_idx_1_based; - if n < 1: return None - while n > 0: n, remainder = divmod(n - 1, 26); string = chr(65 + remainder) + string + """ Konvertiert 1-basierten Spaltenindex in Buchstaben (A, B, ..., Z, AA, ...). """ + string = "" + n = col_idx_1_based + if n < 1: return None # Ungültiger Index + while n > 0: + n, remainder = divmod(n - 1, 26) + string = chr(65 + remainder) + string return string - # Prüft jetzt auf Werte in der `empty_values` Liste (case-insensitive) - def get_start_row_index(self, check_column_key, min_sheet_row=7, empty_values=None): - """Findet erste Zeile, deren Wert in check_column_key als leer gilt.""" - # --- KORRIGIERT: Standardwerte für leere Strings --- - if empty_values is None: - empty_values = ["", "k.a.", "k.a. (nur cookie-banner erkannt)", "k.a. (fehler)"] + # --- ANGEPASST: Sucht jetzt nur noch nach EXAKT LEER ("") --- + def get_start_row_index(self, check_column_key, min_sheet_row=7): + """ + Findet den Index der ersten Zeile (0-basiert für Daten nach Header), + ab einer Mindestzeilennummer im Sheet, in der der Wert in der + Spalte (definiert durch check_column_key) EXAKT LEER ("") ist. + Lädt die Daten vor der Prüfung neu. + + Args: + check_column_key (str): Der Schlüssel in COLUMN_MAP für die zu prüfende Spalte. + min_sheet_row (int): Die 1-basierte Zeilennummer im Sheet, ab der gesucht werden soll. + + Returns: + int: Der 0-basierte Index in der Datenliste (ohne Header), + oder -1 bei Fehler (z.B. Schlüssel nicht gefunden), + oder der Index nach der letzten Datenzeile, wenn alle gefüllt sind. + """ + # Lade Daten *vor* der Prüfung neu, um Aktualität sicherzustellen + if not self.load_data(): + debug_print("FEHLER beim Laden der Daten in get_start_row_index. Breche ab.") + return -1 # Fehlerindikator - if not self.load_data(): return -1 header_rows = 5 - data_rows = self.get_data() - if not data_rows: return 0 + data_rows = self.get_data() # Greift auf die neu geladenen Daten zu + if not data_rows: + debug_print("Keine Datenzeilen vorhanden für get_start_row_index nach Neuladen.") + return 0 # Index 0 signalisiert Start am Anfang (oder keine Daten) + + # Hole den Spaltenindex aus COLUMN_MAP check_column_index = COLUMN_MAP.get(check_column_key) if check_column_index is None: debug_print(f"FEHLER: Schlüssel '{check_column_key}' nicht in COLUMN_MAP gefunden!") - return -1 + return -1 # Fehlerindikator actual_col_letter = self._get_col_letter(check_column_index + 1) + # Berechne den 0-basierten Startindex für die *Datenliste* data_rows search_start_index_in_data = max(0, min_sheet_row - header_rows - 1) - debug_print(f"get_start_row_index: Suche ab Daten-Index {search_start_index_in_data} nach Wert in {empty_values} in Spalte '{check_column_key}' ({actual_col_letter})...") + debug_print(f"get_start_row_index: Suche ab Daten-Index {search_start_index_in_data} (Sheet-Zeile {search_start_index_in_data + header_rows + 1}) nach EXAKT LEEREM Wert (=='') in Spalte '{check_column_key}' ({actual_col_letter})...") if search_start_index_in_data >= len(data_rows): - debug_print(f"Start-Suchindex ({search_start_index_in_data}) >= Datenlänge ({len(data_rows)}). Alle geprüft.") - return len(data_rows) + debug_print(f"Start-Suchindex ({search_start_index_in_data}) >= Datenlänge ({len(data_rows)}). Alle vorherigen Zeilen scheinen gefüllt.") + return len(data_rows) # Index nach der letzten Zeile + # Durchlaufe die Datenzeilen ab dem berechneten Startindex for i in range(search_start_index_in_data, len(data_rows)): row = data_rows[i] current_sheet_row = i + header_rows + 1 - cell_value_str_lower = "FEHLER_INDEX" # Fallback - is_considered_empty = True # Annahme: Ist leer + cell_value = "" # Standardwert, falls Spalte nicht existiert + is_exactly_empty = False if len(row) > check_column_index: - cell_value_str_lower = str(row[check_column_index]).strip().lower() - if cell_value_str_lower not in empty_values: - is_considered_empty = False - # else: is_considered_empty bleibt True (Spalte zu kurz = leer) + cell_value = row[check_column_index] # Hole den Rohwert + # Prüfe, ob der Wert EXAKT ein leerer String ist + if cell_value == "": + is_exactly_empty = True + else: + # Spalte existiert nicht -> gilt als leer + is_exactly_empty = True # Logge nur relevante Prüfungen - if i == search_start_index_in_data or i % 1000 == 0 or is_considered_empty: - debug_print(f" -> Prüfe Daten-Index {i} (Sheet {current_sheet_row}): Wert in {actual_col_letter}='{cell_value_str_lower}'. Gilt als leer? {is_considered_empty}") + log_debug = (i == search_start_index_in_data or i % 1000 == 0 or is_exactly_empty or i in range(10110, 10116)) # Logge um Zeile 10113 + if log_debug: + debug_print(f" -> Prüfe Daten-Index {i} (Sheet {current_sheet_row}): Wert in {actual_col_letter}='{cell_value}' (Typ: {type(cell_value)}). Ist exakt leer ('')? {is_exactly_empty}") - if is_considered_empty: - debug_print(f"Erste Zeile ab {min_sheet_row} mit leerem Wert in Spalte {actual_col_letter} gefunden: Zeile {current_sheet_row} (Daten-Index {i})") - return i + if is_exactly_empty: + debug_print(f"Erste Zeile ab {min_sheet_row} mit EXAKT LEEREM Wert in Spalte {actual_col_letter} gefunden: Zeile {current_sheet_row} (Daten-Index {i})") + return i # Gibt den 0-basierten Index *innerhalb der Datenliste* zurück + # Wenn die Schleife durchläuft, sind alle Zeilen ab dem Start gefüllt last_index = len(data_rows) debug_print(f"Alle Zeilen ab Daten-Index {search_start_index_in_data} haben einen nicht-leeren Wert in Spalte {actual_col_letter}. Nächster Daten-Index wäre {last_index}.") return last_index + # retry_on_failure Decorator sollte hier angewendet werden @retry_on_failure def batch_update_cells(self, update_data): - # ... (unverändert) ... - if not self.sheet: return False - if not update_data: return True - try: self.sheet.batch_update(update_data, value_input_option='USER_ENTERED'); return True - except Exception as e: debug_print(f"FEHLER Batch Update: {e}"); raise e + """ + Führt ein Batch-Update im Google Sheet durch. Beinhaltet Fehlerbehandlung. + + Args: + update_data (list): Eine Liste von Dictionaries, jedes mit 'range' und 'values'. + z.B. [{'range': 'A1', 'values': [['Wert']]}, ...] + + Returns: + bool: True bei Erfolg, False bei Fehler nach Retries. + """ + if not self.sheet: + debug_print("FEHLER: Keine Sheet-Verbindung für Batch-Update.") + return False + if not update_data: + return True # Kein Fehler, aber nichts zu tun + + try: + self.sheet.batch_update(update_data, value_input_option='USER_ENTERED') + return True + except gspread.exceptions.APIError as e: + debug_print(f"Google API Fehler beim Batch-Update: Status {e.response.status_code} - {e.response.text[:500]}") + raise e # Fehler weitergeben, damit der Decorator ihn fängt + except Exception as e: + debug_print(f"Allgemeiner Fehler beim Batch-Update: {type(e).__name__} - {e}") + raise e # Fehler weitergeben # --- Ende GoogleSheetHandler Klasse --- @@ -2177,13 +2265,12 @@ def process_website_batch(sheet_handler, start_row_index_in_sheet, end_row_index """ debug_print(f"Starte Website-Scraping NUR ROHDATEN (Batch) für Zeilen {start_row_index_in_sheet} bis {end_row_index_in_sheet}...") - # --- Lade Daten --- if not sheet_handler.load_data(): return all_data = sheet_handler.get_all_data_with_headers() if not all_data or len(all_data) <= 5: return header_rows = 5 - # --- Indizes und Buchstaben --- + # Indizes und Buchstaben rohtext_col_key = "Website Rohtext" rohtext_col_index = COLUMN_MAP.get(rohtext_col_key) website_col_idx = COLUMN_MAP.get("CRM Website") @@ -2194,14 +2281,14 @@ def process_website_batch(sheet_handler, start_row_index_in_sheet, end_row_index rohtext_col_letter = sheet_handler._get_col_letter(rohtext_col_index + 1) version_col_letter = sheet_handler._get_col_letter(version_col_idx + 1) - # --- Worker-Funktion (unverändert) --- + # Worker-Funktion (unverändert) def scrape_raw_text_task(task_info): row_num = task_info['row_num']; url = task_info['url']; raw_text = "k.A."; error = None try: raw_text = get_website_raw(url) except Exception as e: error = f"Scraping Fehler Zeile {row_num}: {e}"; debug_print(error) return {"row_num": row_num, "raw_text": raw_text, "error": error} - # --- Hauptlogik: Iteriere und sammle Batches --- + # Hauptlogik tasks_for_processing_batch = [] all_sheet_updates = [] total_processed_count = 0 @@ -2209,11 +2296,10 @@ def process_website_batch(sheet_handler, start_row_index_in_sheet, end_row_index total_skipped_url_count = 0 total_error_count = 0 - # --- KORRIGIERT: Hole Konfigurationswerte aus Config --- processing_batch_size = Config.PROCESSING_BATCH_SIZE max_scraping_workers = Config.MAX_SCRAPING_WORKERS update_batch_row_limit = Config.UPDATE_BATCH_ROW_LIMIT - # --- Ende Korrektur --- + # Diese Liste wird weiterhin für die Skip-Logik *innerhalb* der Funktion verwendet empty_values_for_skip = ["", "k.a.", "k.a. (nur cookie-banner erkannt)", "k.a. (fehler)"] for i in range(start_row_index_in_sheet, end_row_index_in_sheet + 1): @@ -2221,21 +2307,24 @@ def process_website_batch(sheet_handler, start_row_index_in_sheet, end_row_index if row_index_in_list >= len(all_data): continue row = all_data[row_index_in_list] - # Prüfung, ob AR schon Inhalt hat + # --- Prüfung, ob AR schon Inhalt hat (bleibt gleich!) --- should_skip = False cell_value_ar_str_lower = "FEHLER_INDEX" if len(row) > rohtext_col_index: cell_value_ar_str_lower = str(row[rohtext_col_index]).strip().lower() + # Überspringen, wenn der Wert NICHT in der Liste der leeren Werte ist if cell_value_ar_str_lower not in empty_values_for_skip: should_skip = True + # else: Spalte existiert nicht -> nicht überspringen - log_debug = (i < start_row_index_in_sheet + 5 or i > end_row_index_in_sheet - 5 or i % 500 == 0 or i in [10, 13]) + log_debug = (i < start_row_index_in_sheet + 5 or i > end_row_index_in_sheet - 5 or i % 500 == 0) if log_debug: debug_print(f"Zeile {i} (Website AR Check): Prüfe Inhalt Spalte {rohtext_col_letter}. Wert='{cell_value_ar_str_lower}'. Überspringen (da schon Inhalt)? -> {should_skip}") if should_skip: total_skipped_count += 1 continue + # --- Ende AR Prüfung --- # URL Prüfung website_url = row[website_col_idx] if len(row) > website_col_idx else "" @@ -2243,52 +2332,36 @@ def process_website_batch(sheet_handler, start_row_index_in_sheet, end_row_index total_skipped_url_count += 1 continue + # Task hinzufügen, wenn AR leer/k.A. war UND URL vorhanden ist tasks_for_processing_batch.append({"row_num": i, "url": website_url}) - # Verarbeitungs-Batch ausführen + # --- Verarbeitungs-Batch ausführen (Logik unverändert) --- if len(tasks_for_processing_batch) >= processing_batch_size or i == end_row_index_in_sheet: if tasks_for_processing_batch: - batch_start_row = tasks_for_processing_batch[0]['row_num'] - batch_end_row = tasks_for_processing_batch[-1]['row_num'] - batch_task_count = len(tasks_for_processing_batch) - debug_print(f"\n--- Starte Scraping-Batch ({batch_task_count} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---") - - scraping_results = {} - debug_print(f" Scrape {batch_task_count} Websites parallel (max {max_scraping_workers} worker)...") + # ... (Paralleles Scraping wie zuvor) ... + # ... (Sheet Updates vorbereiten wie zuvor - nur AR und AP) ... + # ... (Updates sammeln und senden wie zuvor) ... + batch_start_row = tasks_for_processing_batch[0]['row_num']; batch_end_row = tasks_for_processing_batch[-1]['row_num'] + batch_task_count = len(tasks_for_processing_batch); debug_print(f"\n--- Starte Scraping-Batch ({batch_task_count} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---") + scraping_results = {}; debug_print(f" Scrape {batch_task_count} Websites parallel (max {max_scraping_workers} worker)...") with concurrent.futures.ThreadPoolExecutor(max_workers=max_scraping_workers) as executor: + # ... (executor Logik) ... future_to_task = {executor.submit(scrape_raw_text_task, task): task for task in tasks_for_processing_batch} for future in concurrent.futures.as_completed(future_to_task): - task = future_to_task[future] - try: - result = future.result() - scraping_results[result['row_num']] = result['raw_text'] - if result['error']: total_error_count += 1 - except Exception as exc: - row_num = task['row_num']; err_msg = f"Generischer Fehler Scraping Task Zeile {row_num}: {exc}" - debug_print(err_msg); scraping_results[row_num] = "k.A. (Fehler)"; total_error_count +=1 - - current_batch_processed_count = len(scraping_results) - total_processed_count += current_batch_processed_count - debug_print(f" Scraping für Batch beendet. {current_batch_processed_count} Ergebnisse erhalten.") - - # Sheet Updates vorbereiten (AR und AP) + # ... (Ergebnisse sammeln) ... + task = future_to_task[future]; try: result = future.result(); scraping_results[result['row_num']] = result['raw_text']; + except Exception as exc: row_num = task['row_num']; err_msg = f"Generischer Fehler Scraping Task Zeile {row_num}: {exc}"; debug_print(err_msg); scraping_results[row_num] = "k.A. (Fehler)"; total_error_count +=1; + current_batch_processed_count = len(scraping_results); total_processed_count += current_batch_processed_count; debug_print(f" Scraping für Batch beendet. {current_batch_processed_count} Ergebnisse erhalten.") if scraping_results: - current_version = Config.VERSION - batch_sheet_updates = [] + # ... (Sheet Updates vorbereiten - AR/AP) ... + current_version = Config.VERSION; batch_sheet_updates = [] for row_num, raw_text_res in scraping_results.items(): - row_updates = [ - {'range': f'{rohtext_col_letter}{row_num}', 'values': [[raw_text_res]]}, - {'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]} - ] - batch_sheet_updates.extend(row_updates) + row_updates = [{'range': f'{rohtext_col_letter}{row_num}', 'values': [[raw_text_res]]}, {'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]}]; batch_sheet_updates.extend(row_updates) all_sheet_updates.extend(batch_sheet_updates) - tasks_for_processing_batch = [] # Batch leeren - - # Sheet Updates senden (wenn update_batch_row_limit erreicht) - if len(all_sheet_updates) >= update_batch_row_limit * 2: # *2 Updates pro Zeile - debug_print(f" Sende gesammelte Sheet-Updates ({len(all_sheet_updates)} Zellen)...") - success = sheet_handler.batch_update_cells(all_sheet_updates) + if len(all_sheet_updates) >= update_batch_row_limit * 2: + # ... (Sheet Updates senden) ... + debug_print(f" Sende gesammelte Sheet-Updates ({len(all_sheet_updates)} Zellen)..."); success = sheet_handler.batch_update_cells(all_sheet_updates) if success: debug_print(f" Sheet-Update bis Zeile {i} erfolgreich.") else: debug_print(f" FEHLER beim Sheet-Update bis Zeile {i}.") all_sheet_updates = [] @@ -2556,21 +2629,26 @@ def process_branch_batch(sheet_handler, start_row_index_in_sheet, end_row_index_ # - Globale Konstante header_rows (oder besser, hol sie vom sheet_handler?) # Komplette run_dispatcher Funktion (Start immer basierend auf AO) +# Komplette run_dispatcher Funktion (Keine Änderungen hier nötig) def run_dispatcher(mode, sheet_handler, row_limit=None): - """Wählt passenden Batch-Prozess, ermittelt Startzeile dynamisch.""" + """ + Wählt den passenden Batch-Prozess basierend auf dem Modus. + Ermittelt die Startzeile dynamisch basierend auf der relevanten Spalte für den Modus. + """ debug_print(f"Starte Dispatcher im Modus '{mode}' mit row_limit={row_limit}.") header_rows = 5 # Startspalte für jeden Modus start_col_key = "Timestamp letzte Prüfung" # Standard AO min_start_row = 7 - if mode == "website": start_col_key = "Website Rohtext" # AR ! + if mode == "website": start_col_key = "Website Rohtext" # AR elif mode == "wiki": start_col_key = "Wiki Verif. Timestamp" # AX elif mode == "branch": start_col_key = "Timestamp letzte Prüfung" # AO elif mode == "summarize": start_col_key = "Website Zusammenfassung" # AS elif mode == "combined": start_col_key = "Timestamp letzte Prüfung" # AO debug_print(f"Dispatcher: Ermittle Startzeile basierend auf Spalte '{start_col_key}'...") + # get_start_row_index prüft jetzt auf exakt "" start_data_index = sheet_handler.get_start_row_index(check_column_key=start_col_key, min_sheet_row=min_start_row) if start_data_index == -1: return debug_print(f"FEHLER: Startspalte '{start_col_key}' prüfen!")