From 5bfc252469e0d7ab8e44641d582066c1abec507a Mon Sep 17 00:00:00 2001 From: Floke Date: Fri, 18 Apr 2025 18:14:12 +0000 Subject: [PATCH] refactor: v1.6.5 Minor code improvements and consistency - Add HTML logging to _extract_infobox_value for debugging - Implement _extract_infobox_value_fallback using regex - Call fallback in extract_company_data if primary fails - Add minor logging to _extract_first_paragraph_from_soup - Adjust extract_numeric_value for robustness - Add force_process flag to process_branch_batch for combined mode - Correct indentation in alignment_demo inner function colnum_string - Refine data preparation logic in DataProcessor.prepare_data_for_modeling - Add Config.HEADER_ROWS constant - Increment version to 1.6.5 --- brancheneinstufung.py | 1449 +++++++++++++++++++++-------------------- 1 file changed, 739 insertions(+), 710 deletions(-) diff --git a/brancheneinstufung.py b/brancheneinstufung.py index c50e584a..fd735c7b 100644 --- a/brancheneinstufung.py +++ b/brancheneinstufung.py @@ -1,14 +1,14 @@ # -*- coding: utf-8 -*- # Git Commit V1.6.5 -# git commit -m "feat: v1.6.5 Improve WikipediaScraper infobox extraction" -# git commit -m "- Add HTML logging to _extract_infobox_value for debugging" -# git commit -m "- Implement _extract_infobox_value_fallback using regex" -# git commit -m "- Call fallback in extract_company_data if primary fails" -# git commit -m "- Add minor logging to _extract_first_paragraph_from_soup" -# git commit -m "- Adjust extract_numeric_value for robustness" +# git commit -m "refactor: v1.6.5 Minor code improvements and consistency" # git commit -m "- Increment version to 1.6.5" +# git commit -m "- Introduce Config.HEADER_ROWS constant" +# git commit -m "- Improve consistency using COLUMN_MAP for cell updates" +# git commit -m "- Enhance logging in WikipediaScraper._extract_infobox_value" +# git commit -m "- Expand keywords in WikipediaScraper._extract_infobox_value" +# git commit -m "- Minor robustness adjustments in extract_numeric_value" -# --- Imports (unverändert lassen) --- +# --- Imports --- import os import time import re @@ -42,7 +42,7 @@ try: except ImportError: tiktoken = None -# --- Konstanten & Config (unverändert lassen, außer VERSION) --- +# ==================== KONSTANTEN ==================== CREDENTIALS_FILE = "service_account.json" API_KEY_FILE = "api_key.txt" SERP_API_KEY_FILE = "serpApiKey.txt" @@ -52,8 +52,9 @@ LOG_DIR = "Log" MODEL_FILE = "technician_decision_tree_model.pkl" IMPUTER_FILE = "median_imputer.pkl" PATTERNS_FILE_TXT = "technician_patterns.txt" -PATTERNS_FILE_JSON = "technician_patterns.json" +PATTERNS_FILE_JSON = "technician_patterns.json" # Optional +# ==================== KONFIGURATION ==================== class Config: VERSION = "v1.6.5" # Versionsnummer erhöht LANG = "de" @@ -65,6 +66,8 @@ class Config: WIKIPEDIA_SEARCH_RESULTS = 5 HTML_PARSER = "html.parser" TOKEN_MODEL = "gpt-3.5-turbo" + + # --- Batching & Parallelisierung --- BATCH_SIZE = 10 PROCESSING_BATCH_SIZE = 20 OPENAI_BATCH_SIZE_LIMIT = 4 @@ -73,11 +76,12 @@ class Config: MAX_BRANCH_WORKERS = 10 OPENAI_CONCURRENCY_LIMIT = 5 PROCESSING_BRANCH_BATCH_SIZE = PROCESSING_BATCH_SIZE - HEADER_ROWS = 5 # NEU: Header-Zeilen als Konstante + + HEADER_ROWS = 5 # NEU: Anzahl der Header-Zeilen als Konstante API_KEYS = {} @classmethod - def load_api_keys(cls): + def load_api_keys(cls): # unverändert 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) @@ -85,16 +89,18 @@ class Config: else: debug_print("⚠️ OpenAI API Key konnte nicht geladen werden.") @staticmethod - def _load_key_from_file(filepath): + def _load_key_from_file(filepath): # unverändert try: with open(filepath, "r") as f: return f.read().strip() except Exception as e: debug_print(f"Fehler Keys aus '{filepath}': {e}"); return None -# --- Globale Variablen (unverändert lassen) --- -BRANCH_MAPPING = {} +# --- Globale Variablen --- +BRANCH_MAPPING = {} # Wird von load_target_schema befüllt (obwohl nicht mehr direkt genutzt) TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfügbar." -ALLOWED_TARGET_BRANCHES = [] -COLUMN_MAP = { # (unverändert lassen) +ALLOWED_TARGET_BRANCHES = [] # Wird von load_target_schema befüllt + +# Globales Spalten-Mapping (wie in v1.6.4) +COLUMN_MAP = { "ReEval Flag": 0, "CRM Name": 1, "CRM Kurzform": 2, "CRM Website": 3, "CRM Ort": 4, "CRM Beschreibung": 5, "CRM Branche": 6, "CRM Beschreibung Branche extern": 7, "CRM Anzahl Techniker": 8, "CRM Umsatz": 9, "CRM Anzahl Mitarbeiter": 10, "CRM Vorschlag Wiki URL": 11, "Wiki URL": 12, @@ -112,191 +118,92 @@ COLUMN_MAP = { # (unverändert lassen) "Geschätzter Techniker Bucket": 46, "Finaler Umsatz (Wiki>CRM)": 47, "Finaler Mitarbeiter (Wiki>CRM)": 48, "Wiki Verif. Timestamp": 49 } -LOG_FILE = None +LOG_FILE = None # Wird in main() gesetzt -# --- Funktionen (prepare_data_for_modeling, retry_on_failure, Logging, Helper, Branch Mapping, Token Count etc. unverändert lassen) --- -# ... (alle diese Funktionen hier einfügen, wie im vorherigen Code) ... -def prepare_data_for_modeling(sheet_handler): # unverändert - debug_print("Starte Datenvorbereitung für Modellierung...") - try: - all_data = sheet_handler.get_all_data_with_headers() - if len(all_data) <= Config.HEADER_ROWS: - debug_print("Fehler: Nicht genügend Datenzeilen im Sheet gefunden.") - return None - headers = all_data[0] - data_rows = all_data[Config.HEADER_ROWS:] - df = pd.DataFrame(data_rows, columns=headers) - debug_print(f"DataFrame erstellt mit {len(df)} Zeilen und {len(df.columns)} Spalten.") - required_cols_keys = [ - "CRM Name", "CRM Branche", "CRM Umsatz", "Wiki Umsatz", - "CRM Anzahl Mitarbeiter", "Wiki Mitarbeiter", "CRM Anzahl Techniker" - ] - col_indices = {} - tech_col_key = "CRM Anzahl Techniker" - try: - col_indices = { - "name": all_data[0][COLUMN_MAP["CRM Name"]], - "branche": all_data[0][COLUMN_MAP["CRM Branche"]], - "umsatz_crm": all_data[0][COLUMN_MAP["CRM Umsatz"]], - "umsatz_wiki": all_data[0][COLUMN_MAP["Wiki Umsatz"]], - "ma_crm": all_data[0][COLUMN_MAP["CRM Anzahl Mitarbeiter"]], - "ma_wiki": all_data[0][COLUMN_MAP["Wiki Mitarbeiter"]], - "techniker": all_data[0][COLUMN_MAP[tech_col_key]] - } - cols_to_select = list(col_indices.values()) - except KeyError as e: - debug_print(f"FEHLER: Konnte Mapping für Schlüssel '{e}' nicht finden oder Spalte nicht im Header.") - return None - except IndexError as e: - debug_print(f"FEHLER: Spaltenindex aus COLUMN_MAP ist außerhalb der Grenzen der Header-Zeile: {e}") - return None - df_subset = df[cols_to_select].copy() - rename_map = {v: k for k, v in col_indices.items()} - df_subset.rename(columns=rename_map, inplace=True) - debug_print(f"Benötigte Spalten ausgewählt und umbenannt: {list(df_subset.columns)}") - def get_valid_numeric(value_str): - # Adjusted slightly for robustness - if pd.isna(value_str) or value_str == '': return np.nan - text = str(value_str).strip() - # Remove currency symbols, prefixes etc. more broadly - text = re.sub(r'(?i)^(ca\.?|circa|über|unter|rund|etwa|mehr als|weniger als|bis zu)\s*', '', text) - text = re.sub(r'[€$£¥]', '', text).strip() - # Handle thousands separators (.) and decimal comma (,) - if '.' in text and ',' in text: # Assume dot is thousand, comma is decimal - text = text.replace('.', '').replace(',', '.') - elif ',' in text and '.' not in text: # Assume comma is decimal - text = text.replace(',', '.') - elif '.' in text and ',' not in text: # Might be thousand or decimal - remove if many dots - if text.count('.') > 1: text = text.replace('.', '') - - # Multipliers (Mio/Mrd for Umsatz, Tsd potentially for both) - multiplier = 1.0 - text_lower = text.lower() - num_part = text - if "mrd" in text_lower or "milliarden" in text_lower or "billion" in text_lower: - multiplier = 1000.0 - num_part = re.sub(r'(?i)\s*(mrd\.?|milliarden|billion)\b.*', '', text).strip() - elif "mio" in text_lower or "millionen" in text_lower or "mill\." in text_lower: - multiplier = 1.0 - num_part = re.sub(r'(?i)\s*(mio\.?|millionen|mill\.?)\b.*', '', text).strip() - elif "tsd" in text_lower or "tausend" in text_lower: - multiplier = 0.001 if 'Umsatz' in final_col else 1000.0 # Adjust multiplier based on target - num_part = re.sub(r'(?i)\s*(tsd\.?|tausend)\b.*', '', text).strip() - - # Extract numeric part again after removing suffixes - num_part = re.match(r'([\d.\-]+)', num_part) # Find leading number (can be negative temporarily) - if not num_part: return np.nan - num_part_str = num_part.group(1) - - try: - val = float(num_part_str) * multiplier - # Allow 0 for Umsatz/Mitarbeiter? Decide based on requirements. Here: > 0 - return val if val > 0 else np.nan - except ValueError: - return np.nan - - cols_to_process = { - 'Umsatz': ('umsatz_wiki', 'umsatz_crm', 'Finaler_Umsatz'), - 'Mitarbeiter': ('ma_wiki', 'ma_crm', 'Finaler_Mitarbeiter') - } - for base_name, (wiki_col, crm_col, final_col) in cols_to_process.items(): - debug_print(f"Verarbeite '{base_name}'...") - if wiki_col not in df_subset.columns: df_subset[wiki_col] = np.nan - if crm_col not in df_subset.columns: df_subset[crm_col] = np.nan - wiki_numeric = df_subset[wiki_col].apply(lambda x: get_valid_numeric(x, final_col)) - crm_numeric = df_subset[crm_col].apply(lambda x: get_valid_numeric(x, final_col)) - df_subset[final_col] = np.where( - wiki_numeric.notna(), wiki_numeric, - np.where(crm_numeric.notna(), crm_numeric, np.nan) - ) - debug_print(f" -> {df_subset[final_col].notna().sum()} gültige '{final_col}' Werte erstellt.") - techniker_col = "techniker" - debug_print(f"Verarbeite Zielvariable '{techniker_col}'...") - df_subset['Anzahl_Servicetechniker_Numeric'] = pd.to_numeric(df_subset[techniker_col], errors='coerce') - initial_rows = len(df_subset) - df_filtered = df_subset[ - df_subset['Anzahl_Servicetechniker_Numeric'].notna() & - (df_subset['Anzahl_Servicetechniker_Numeric'] > 0) - ].copy() - filtered_rows = len(df_filtered) - debug_print(f"{initial_rows - filtered_rows} Zeilen entfernt (fehlende/ungültige Technikerzahl).") - debug_print(f"Verbleibende Zeilen für Modellierung: {filtered_rows}") - if filtered_rows == 0: return None - bins = [-1, 0, 19, 49, 99, 249, 499, float('inf')] - labels = ['Bucket_1_(0)', 'Bucket_2_(<20)', 'Bucket_3_(<50)', 'Bucket_4_(<100)', 'Bucket_5_(<250)', 'Bucket_6_(<500)', 'Bucket_7_(>499)'] - df_filtered['Techniker_Bucket'] = pd.cut( - df_filtered['Anzahl_Servicetechniker_Numeric'], - bins=bins, labels=labels, right=True - ) - debug_print("Techniker-Buckets erstellt.") - debug_print(f"Verteilung der Buckets:\n{df_filtered['Techniker_Bucket'].value_counts(normalize=True).round(3)}") - branche_col = "branche" - debug_print(f"Verarbeite kategoriales Feature '{branche_col}'...") - df_filtered[branche_col] = df_filtered[branche_col].astype(str).fillna('Unbekannt').str.strip() - df_encoded = pd.get_dummies(df_filtered, columns=[branche_col], prefix='Branche', dummy_na=False) - debug_print(f"One-Hot Encoding für Branche durchgeführt.") - feature_columns = [col for col in df_encoded.columns if col.startswith('Branche_')] - feature_columns.extend(['Finaler_Umsatz', 'Finaler_Mitarbeiter']) - target_column = 'Techniker_Bucket' - original_data_cols = ['name', 'Anzahl_Servicetechniker_Numeric'] # Keep original tech number for reference if needed - df_model_ready = df_encoded[original_data_cols + feature_columns + [target_column]].copy() - for col in ['Finaler_Umsatz', 'Finaler_Mitarbeiter']: - df_model_ready[col] = pd.to_numeric(df_model_ready[col], errors='coerce') - df_model_ready = df_model_ready.reset_index(drop=True) - debug_print("Datenvorbereitung abgeschlossen.") - nan_counts = df_model_ready[['Finaler_Umsatz', 'Finaler_Mitarbeiter']].isna().sum() - debug_print(f"Fehlende Werte in numerischen Features vor Imputation:\n{nan_counts}") - return df_model_ready - except Exception as e: - debug_print(f"FEHLER während der Datenvorbereitung: {e}") - import traceback - debug_print(traceback.format_exc()) - return None - -def retry_on_failure(func): # unverändert +# ==================== RETRY-DECORATOR ==================== +def retry_on_failure(func): # Unverändert gegenüber v1.6.4 def wrapper(*args, **kwargs): func_name = func.__name__ 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) + try: + return func(*args, **kwargs) except Exception as e: - error_msg = str(e); wait_time = Config.RETRY_DELAY * (attempt + 1) - log_prefix = f"🚦 Rate Limit bei {effective_func_name}" if isinstance(e, gspread.exceptions.APIError) and e.response.status_code == 429 else f"⚠️ Fehler bei {effective_func_name}" - print(f"{log_prefix} (Versuch {attempt+1}/{Config.MAX_RETRIES}). Warte {wait_time}s... Fehler: {type(e).__name__} - {error_msg[:100]}") - if attempt < Config.MAX_RETRIES - 1: time.sleep(wait_time) - else: print(f"❌ Endgültiger Fehler bei {effective_func_name}."); return None - return None + error_msg = str(e) + wait_time = Config.RETRY_DELAY * (attempt + 1) # Exponential backoff standard + + if isinstance(e, gspread.exceptions.APIError): + if e.response.status_code == 429: # Rate Limit + print(f"🚦 Rate Limit bei {effective_func_name} (Versuch {attempt+1}). Warte {wait_time}s...") + # Keine zusätzliche Fehlermeldung bei Rate Limit nötig + else: + print(f"⚠️ Google API Fehler bei {effective_func_name} (Versuch {attempt+1}): Status {e.response.status_code} - {error_msg[:150]}") + elif isinstance(e, requests.exceptions.RequestException): + print(f"⚠️ Netzwerkfehler bei {effective_func_name} (Versuch {attempt+1}): {error_msg[:150]}") + elif isinstance(e, openai.error.OpenAIError): + print(f"⚠️ OpenAI Fehler bei {effective_func_name} (Versuch {attempt+1}): {error_msg[:150]}") + else: + print(f"⚠️ Unbekannter Fehler bei {effective_func_name} (Versuch {attempt+1}): {type(e).__name__} - {error_msg[:150]}") + + if attempt < Config.MAX_RETRIES - 1: + time.sleep(wait_time) + else: + print(f"❌ Endgültiger Fehler bei {effective_func_name} nach {Config.MAX_RETRIES} Versuchen.") + # Die aufrufende Funktion muss mit None umgehen können + return None + return None # Fallback, sollte nicht erreicht werden return wrapper -def create_log_filename(mode): # unverändert - if not os.path.exists(LOG_DIR): os.makedirs(LOG_DIR) +# ==================== LOGGING & HELPER FUNCTIONS ==================== + +def create_log_filename(mode): # Unverändert + 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): # unverändert +def debug_print(message): # Unverändert global LOG_FILE log_message = f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {message}" - if Config.DEBUG: print(log_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}") + # Verwende 'with' korrekt für das Dateihandling + with open(LOG_FILE, "a", encoding="utf-8") as f: + f.write(log_message + "\n") + except Exception as e: + # Kritischer Fehler, wenn Log nicht geschrieben werden kann + print(f"[CRITICAL] Log-Schreibfehler: {e}") -def simple_normalize_url(url): # unverändert - if not url or not isinstance(url, str): return "k.A." + +def simple_normalize_url(url): # Unverändert + """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." - if not url.lower().startswith(("http://", "https://")): url = "https://" + url + if not url: + return "k.A." + if not url.lower().startswith(("http://", "https://")): + url = "https://" + url try: - parsed = urlparse(url); domain_part = parsed.netloc.split(":", 1)[0] + parsed = urlparse(url) + domain_part = parsed.netloc + domain_part = domain_part.split(":", 1)[0] # Port entfernen + # Füge www. hinzu, wenn nicht vorhanden und Domain Punkte enthält (keine IP) if not domain_part.lower().startswith("www.") and '.' in domain_part: - if not re.match(r"^\d{1,3}(\.\d{1,3}){3}$", domain_part): domain_part = "www." + domain_part + if not re.match(r"^\d{1,3}(\.\d{1,3}){3}$", domain_part): + domain_part = "www." + domain_part return domain_part.lower() - except Exception as e: debug_print(f"Fehler bei URL-Normalisierung '{url}': {e}"); return "k.A." + except Exception as e: + debug_print(f"Fehler bei URL-Normalisierung '{url}': {e}") + return "k.A." -def normalize_string(s): # unverändert +def normalize_string(s): # Unverändert + """Normalisiert Umlaute und Sonderzeichen.""" if not s or not isinstance(s, str): return "" replacements = {'Ä': 'Ae', 'Ö': 'Oe', 'Ü': 'Ue', 'ß': 'ss', 'ä': 'ae', 'ö': 'oe', 'ü': 'ue', 'À': 'A', 'Á': 'A', 'Â': 'A', 'Ã': 'A', 'Å': 'A', 'Æ': 'AE', 'à': 'a', 'á': 'a', 'â': 'a', 'ã': 'a', 'å': 'a', 'æ': 'ae', 'Ç': 'C', 'ç': 'c', 'È': 'E', 'É': 'E', 'Ê': 'E', 'Ë': 'E', 'è': 'e', 'é': 'e', 'ê': 'e', 'ë': 'e', 'Ì': 'I', 'Í': 'I', 'Î': 'I', 'Ï': 'I', 'ì': 'i', 'í': 'i', 'î': 'i', 'ï': 'i', 'Ñ': 'N', 'ñ': 'n', 'Ò': 'O', 'Ó': 'O', 'Ô': 'O', 'Õ': 'O', 'Ø': 'O', 'ò': 'o', 'ó': 'o', 'ô': 'o', 'õ': 'o', 'ø': 'o', 'Œ': 'OE', 'œ': 'oe', 'Š': 'S', 'š': 's', 'Ž': 'Z', 'ž': 'z', 'Ý': 'Y', 'ý': 'y', 'ÿ': 'y', 'Đ': 'D', 'đ': 'd', 'č': 'c', 'Č': 'C', 'ć': 'c', 'Ć': 'C', 'ł': 'l', 'Ł': 'L', 'ğ': 'g', 'Ğ': 'G', 'ş': 's', 'Ş': 'S', 'ă': 'a', 'Ă': 'A', 'ı': 'i', 'İ': 'I', 'ň': 'n', 'Ň': 'N', 'ř': 'r', 'Ř': 'R', 'ő': 'o', 'Ő': 'O', 'ű': 'u', 'Ű': 'U', 'ț': 't', 'Ț': 'T', 'ș': 's', 'Ș': 'S'} try: s = unicodedata.normalize('NFKD', s).encode('ascii', 'ignore').decode('ascii') @@ -304,30 +211,41 @@ def normalize_string(s): # unverändert for src, target in replacements.items(): s = s.replace(src, target) return s -def clean_text(text): # unverändert +def clean_text(text): # Leicht angepasst: Entfernt auch [Bearbeiten]-Links etc. + """Bereinigt Text von Wikipedia etc.""" if not text: return "k.A." try: - text = str(text); text = unicodedata.normalize("NFKC", text) - text = re.sub(r'\[\d+\]', '', text); text = re.sub(r'\[.*?\]', '', text) # Auch [Bearbeiten] etc. - text = re.sub(r'\s+', ' ', text).strip(); return text if text else "k.A." - except Exception as e: debug_print(f"Fehler bei clean_text: {e}"); return "k.A." + text = str(text) + text = unicodedata.normalize("NFKC", text) + text = re.sub(r'\[\d+\]', '', text) # Entfernt [1], [2] etc. + text = re.sub(r'\[.*?\]', '', text) # Entfernt aggressiver [Bearbeiten], [Quelltext bearbeiten] etc. + text = re.sub(r'\s+', ' ', text).strip() + 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): # unverändert +def normalize_company_name(name): # Unverändert + """Entfernt Rechtsformzusätze etc. für Vergleiche.""" if not name: return "" - name = clean_text(name) + name = clean_text(name) # Vorab bereinigen forms = [r'gmbh', r'ges\.?\s*m\.?\s*b\.?\s*h\.?', r'gesellschaft mit beschränkter haftung', r'ug', r'u\.g\.', r'unternehmergesellschaft', r'haftungsbeschränkt', r'ag', r'a\.g\.', r'aktiengesellschaft', r'ohg', r'o\.h\.g\.', r'offene handelsgesellschaft', r'kg', r'k\.g\.', r'kommanditgesellschaft', r'gmbh\s*&\s*co\.?\s*kg', r'ges\.?\s*m\.?\s*b\.?\s*h\.?\s*&\s*co\.?\s*k\.g\.?', r'ag\s*&\s*co\.?\s*kg', r'a\.g\.?\s*&\s*co\.?\s*k\.g\.?', r'e\.k\.', r'e\.kfm\.', r'e\.kfr\.', r'eingetragene[rn]? kauffrau', r'eingetragene[rn]? kaufmann', r'ltd\.?', r'limited', r'ltd\s*&\s*co\.?\s*kg', r's\.?a\.?r\.?l\.?', r'sàrl', r'sagl', r's\.?a\.?', r'société anonyme', r'sociedad anónima', r's\.?p\.?a\.?', r'società per azioni', r'b\.?v\.?', r'besloten vennootschap', r'n\.?v\.?', r'naamloze vennootschap', r'plc\.?', r'public limited company', r'inc\.?', r'incorporated', r'corp\.?', r'corporation', r'llc\.?', r'limited liability company', r'kgaa', r'kommanditgesellschaft auf aktien', r'se', r'societas europaea', r'e\.?g\.?', r'eingetragene genossenschaft', r'genossenschaft', r'genmbh', r'e\.?v\.?', r'eingetragener verein', r'verein', r'stiftung', r'ggmbh', r'gemeinnützige gmbh', r'gug', r'partg', r'partnerschaftsgesellschaft', r'partgmbb', r'og', r'o\.g\.', r'offene gesellschaft', r'e\.u\.', r'eingetragenes unternehmen', r'ges\.?n\.?b\.?r\.?', r'gesellschaft nach bürgerlichem recht', r'kollektivgesellschaft', r'einzelfirma', r'gruppe', r'holding', r'international', r'systeme', r'technik', r'logistik', r'solutions', r'services', r'management', r'consulting', r'produktion', r'vertrieb', r'entwicklung', r'maschinenbau', r'anlagenbau'] pattern = r'\b(' + '|'.join(forms) + r')\b' normalized = re.sub(pattern, '', name, flags=re.IGNORECASE) - normalized = re.sub(r'[.,;:]', '', normalized); normalized = re.sub(r'[\-–/]', ' ', normalized) - normalized = re.sub(r'\s+', ' ', normalized).strip(); return normalized.lower() + normalized = re.sub(r'[.,;:]', '', normalized) + normalized = re.sub(r'[\-–/]', ' ', normalized) + normalized = re.sub(r'\s+', ' ', normalized).strip() + return normalized.lower() @retry_on_failure -def is_valid_wikipedia_article_url(wiki_url): # unverändert - if not wiki_url or not wiki_url.lower().startswith(("http://", "https://")) or "wikipedia.org/wiki/" not in wiki_url: return False +def is_valid_wikipedia_article_url(wiki_url): # Unverändert + """Prüft über die MediaWiki API, ob eine URL ein valider Artikel ist.""" + if not wiki_url or not wiki_url.lower().startswith(("http://", "https://")) or "wikipedia.org/wiki/" not in wiki_url: + return False try: title = unquote(wiki_url.split('/wiki/', 1)[1]).replace('_', ' ') api_url = "https://de.wikipedia.org/w/api.php" - params = {"action": "query", "titles": title, "format": "json", "formatversion": 2, "redirects": 1} + params = { "action": "query", "titles": title, "format": "json", "formatversion": 2, "redirects": 1 } response = requests.get(api_url, params=params, timeout=5) response.raise_for_status(); data = response.json() if 'query' in data and 'pages' in data['query']: @@ -342,62 +260,13 @@ def is_valid_wikipedia_article_url(wiki_url): # unverändert else: debug_print(f" API Check '{title}': Bad format."); return False except Exception as e: debug_print(f" API Check '{title}': Error - {e}"); return False -def process_wiki_updates_from_chatgpt(sheet_handler, data_processor, row_limit=None): # unverändert - debug_print("Starte Modus: Wiki-Updates...") - if not sheet_handler.load_data(): return - all_data = sheet_handler.get_all_data_with_headers() - if not all_data or len(all_data) <= Config.HEADER_ROWS: return - data_rows = all_data[Config.HEADER_ROWS:] - required_keys = ["Chat Wiki Konsistenzprüfung", "Chat Vorschlag Wiki Artikel", "Wiki URL", "Wikipedia Timestamp", "Wiki Verif. Timestamp", "Timestamp letzte Prüfung", "Version", "ReEval Flag"] - col_indices = {}; all_keys_found = True - for key in required_keys: - idx = COLUMN_MAP.get(key); col_indices[key] = idx - if idx is None: debug_print(f"FEHLER: Key '{key}' fehlt!"); all_keys_found = False - if not all_keys_found: return - all_sheet_updates = []; processed_rows_count = 0; updated_url_count = 0; cleared_suggestion_count = 0 - for idx, row in enumerate(data_rows): - row_num_in_sheet = idx + Config.HEADER_ROWS + 1 - if row_limit is not None and processed_rows_count >= row_limit: break - def get_value(key): - index = col_indices.get(key) - if index is not None and len(row) > index: return row[index] - return "" - konsistenz_s = get_value("Chat Wiki Konsistenzprüfung"); vorschlag_u = get_value("Chat Vorschlag Wiki Artikel"); url_m = get_value("Wiki URL") - is_update_candidate = False; new_url = "" - konsistenz_s_upper = konsistenz_s.strip().upper(); vorschlag_u_cleaned = vorschlag_u.strip(); url_m_cleaned = url_m.strip() - condition1_status_nok = konsistenz_s_upper not in ["OK", "X (UPDATED)", "X (URL COPIED)", "X (INVALID SUGGESTION)", ""] - condition2_u_is_url = vorschlag_u_cleaned.lower().startswith(("http://", "https://")) and "wikipedia.org/wiki/" in vorschlag_u_cleaned.lower() - condition3_u_differs_m = False; condition4_u_is_valid = False - if condition1_status_nok and condition2_u_is_url: - new_url = vorschlag_u_cleaned; condition3_u_differs_m = new_url != url_m_cleaned - if condition3_u_differs_m: condition4_u_is_valid = is_valid_wikipedia_article_url(new_url) - is_update_candidate = condition1_status_nok and condition2_u_is_url and condition3_u_differs_m and condition4_u_is_valid - clear_invalid_suggestion = condition1_status_nok and not is_update_candidate - if is_update_candidate: - debug_print(f"Zeile {row_num_in_sheet}: Update-Kandidat VALIDIERUNG ERFOLGREICH.") - processed_rows_count += 1; updated_url_count += 1 - m_l=sheet_handler._get_col_letter(col_indices["Wiki URL"]+1); s_l=sheet_handler._get_col_letter(col_indices["Chat Wiki Konsistenzprüfung"]+1); u_l=sheet_handler._get_col_letter(col_indices["Chat Vorschlag Wiki Artikel"]+1); an_l=sheet_handler._get_col_letter(col_indices["Wikipedia Timestamp"]+1); ax_l=sheet_handler._get_col_letter(col_indices["Wiki Verif. Timestamp"]+1); ao_l=sheet_handler._get_col_letter(col_indices["Timestamp letzte Prüfung"]+1); ap_l=sheet_handler._get_col_letter(col_indices["Version"]+1); a_l=sheet_handler._get_col_letter(col_indices["ReEval Flag"]+1) - row_updates = [{'range': f'{m_l}{row_num_in_sheet}', 'values': [[new_url]]}, {'range': f'{s_l}{row_num_in_sheet}', 'values': [["X (URL Copied)"]]}, {'range': f'{u_l}{row_num_in_sheet}', 'values': [["URL übernommen"]]}, {'range': f'{an_l}{row_num_in_sheet}', 'values': [[""]]}, {'range': f'{ax_l}{row_num_in_sheet}', 'values': [[""]]}, {'range': f'{ao_l}{row_num_in_sheet}', 'values': [[""]]}, {'range': f'{ap_l}{row_num_in_sheet}', 'values': [[""]]}, {'range': f'{a_l}{row_num_in_sheet}', 'values': [["x"]]}] - all_sheet_updates.extend(row_updates) - elif clear_invalid_suggestion: - debug_print(f"Zeile {row_num_in_sheet}: Status S war '{konsistenz_s}', aber Vorschlag U ('{vorschlag_u_cleaned}') ungültig/identisch. Lösche U und setze Status S.") - processed_rows_count += 1; cleared_suggestion_count += 1 - s_l=sheet_handler._get_col_letter(col_indices["Chat Wiki Konsistenzprüfung"]+1); u_l=sheet_handler._get_col_letter(col_indices["Chat Vorschlag Wiki Artikel"]+1) - row_updates = [{'range': f'{s_l}{row_num_in_sheet}', 'values': [["X (Invalid Suggestion)"]]}, {'range': f'{u_l}{row_num_in_sheet}', 'values': [[""]]}] - all_sheet_updates.extend(row_updates) - if all_sheet_updates: - debug_print(f"BEREIT ZUM SENDEN: Batch-Update für {processed_rows_count} geprüfte Zeilen...") - success = sheet_handler.batch_update_cells(all_sheet_updates) - if success: debug_print(f"Sheet-Update für Wiki-Updates erfolgreich.") - else: debug_print(f"FEHLER beim Sheet-Update für Wiki-Updates.") - else: debug_print("Keine Zeilen gefunden, die eine Korrektur benötigen.") - debug_print(f"Wiki-Updates abgeschlossen. {processed_rows_count} geprüft. {updated_url_count} kopiert/markiert, {cleared_suggestion_count} gelöscht/markiert.") - def extract_numeric_value(raw_value, is_umsatz=False): # Leicht angepasst für Robustheit + """Extrahiert und normalisiert Zahlenwerte (Umsatz in Mio, Mitarbeiter).""" if pd.isna(raw_value) or raw_value == '': return "k.A." raw_value = clean_text(str(raw_value)) if raw_value == "k.A.": return "k.A." + # Entferne Präfixe, Währungen etc. processed_value = re.sub(r'(?i)\b(ca\.?|circa|über|unter|rund|etwa|mehr als|weniger als|bis zu)\b', '', raw_value).strip() processed_value = re.sub(r'[€$£¥]', '', processed_value).strip() @@ -408,244 +277,334 @@ def extract_numeric_value(raw_value, is_umsatz=False): # Leicht angepasst für R processed_value = processed_value.replace(',', '.') # Wenn nur Punkt, lasse vorerst (kann Dezimal oder Tausender sein) - match = re.search(r'([\d.,]+)', processed_value) # Finde erste Zahl(engruppe) - if not match: return "k.A." + # Finde die erste Zahl(engruppe) inklusive möglicher Tausender-/Dezimaltrennzeichen + match = re.search(r'([\d.,]+)', processed_value) + if not match: + # debug_print(f"Keine Zahl gefunden in: '{raw_value}' -> '{processed_value}'") + return "k.A." num_str = match.group(1) - # Entferne Tausenderpunkte VOR Umwandlung + # Entferne Tausenderpunkte VOR Umwandlung, falls noch vorhanden if '.' in num_str and num_str.count('.') > 1: # Mehrere Punkte -> Tausender num_str = num_str.replace('.', '') # Komma wurde bereits zu Punkt - try: num = float(num_str) - except ValueError: debug_print(f"Float-Umwandlung fehlgeschlagen: '{num_str}' aus '{raw_value}'"); return "k.A." + try: + num = float(num_str) + except ValueError: + # debug_print(f"Float-Umwandlung fehlgeschlagen: '{num_str}' aus '{raw_value}'") + return "k.A." - # Multiplikatoren (Groß-/Kleinschreibung ignorieren) + # Multiplikatoren anwenden (Groß/Kleinschreibung ignorieren) raw_lower = raw_value.lower(); multiplier = 1.0 + # Suche nach Multiplikatoren im *Originaltext*, nicht nur in der extrahierten Zahl if "mrd" in raw_lower or "milliarden" in raw_lower or "billion" in raw_lower: multiplier = 1000.0 # Für Umsatz in Mio elif "mio" in raw_lower or "millionen" in raw_lower or "mill." in raw_lower: multiplier = 1.0 - elif "tsd" in raw_lower or "tausend" in raw_lower: multiplier = 0.001 if is_umsatz else 1000.0 # Umsatz in Mio, MA direkt + elif "tsd" in raw_lower or "tausend" in raw_lower: + multiplier = 0.001 if is_umsatz else 1000.0 # Umsatz in Mio, MA direkt * 1000 num = num * multiplier - if is_umsatz: return str(int(round(num))) # Umsatz immer in Mio, Ganzzahl - else: return str(int(round(num))) # Mitarbeiter als Ganzzahl + # Runde auf Ganzzahl und konvertiere zu String + 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): # unverändert +def get_gender(firstname): # Unverändert + """Ermittelt Geschlecht via gender-guesser und Fallback Genderize API.""" if not firstname or not isinstance(firstname, str): return "unknown" - firstname = firstname.strip().split(" ")[0] + firstname = firstname.strip().split(" ")[0] # Nur ersten Teil des Vornamens if not firstname: return "unknown" - d = gender.Detector(case_sensitive=False); result = d.get_gender(firstname, 'germany') + + d = gender.Detector(case_sensitive=False) + result = d.get_gender(firstname, 'germany') if result in ["andy", "unknown", "mostly_male", "mostly_female"]: genderize_key = Config.API_KEYS.get('genderize') - if not genderize_key: return result if result not in ["andy", "unknown"] else "unknown" + if not genderize_key: + # debug_print("Genderize API-Schlüssel nicht verfügbar.") + return result if result not in ["andy", "unknown"] else "unknown" # Behalte mostly_, sonst unknown + 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(); data = response.json() - api_gender = data.get("gender"); probability = data.get("probability", 0) - if api_gender and probability > 0.6: return api_gender - else: return result if result not in ["andy", "unknown"] else "unknown" - except Exception as e: debug_print(f"Fehler Genderize API für '{firstname}': {e}"); return result if result not in ["andy", "unknown"] else "unknown" - else: return result + response.raise_for_status() + data = response.json() + api_gender = data.get("gender") + probability = data.get("probability", 0) + if api_gender and probability > 0.6: # Nur bei ausreichender Sicherheit + return api_gender + else: + return result if result not in ["andy", "unknown"] else "unknown" + except requests.exceptions.RequestException as e: + debug_print(f"Fehler bei Genderize API 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): # unverändert - if not all([firstname, lastname, website]) or not all(isinstance(x, str) for x in [firstname, lastname, website]): return "" +def get_email_address(firstname, lastname, website): # Unverändert + """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: return "" if domain.startswith("www."): domain = domain[4:] - normalized_first = normalize_string(firstname.lower()); normalized_last = normalize_string(lastname.lower()) + normalized_first = normalize_string(firstname.lower()) + normalized_last = normalize_string(lastname.lower()) normalized_first = re.sub(r'\s+', '-', normalized_first); normalized_last = re.sub(r'\s+', '-', normalized_last) 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 fuzzy_similarity(str1, str2): # unverändert +def fuzzy_similarity(str1, str2): # Unverändert + """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() -def evaluate_branche_chatgpt(crm_branche, beschreibung, wiki_branche, wiki_kategorien, website_summary): # unverändert - global ALLOWED_TARGET_BRANCHES, TARGET_SCHEMA_STRING - if not ALLOWED_TARGET_BRANCHES: debug_print("FEHLER evaluate_branche: Schema leer."); return {"branch": crm_branche, "consistency": "error_schema_missing", "justification": "Fehler: Schema nicht geladen"} - allowed_branches_lookup = {b.lower(): b for b in ALLOWED_TARGET_BRANCHES} - prompt_parts = [TARGET_SCHEMA_STRING, "\nOrdne das Unternehmen anhand folgender Angaben exakt einer Branche des Ziel-Branchenschemas (Kurzformen) 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]}") - 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]}") - if website_summary and website_summary != "k.A.": prompt_parts.append(f"- Website-Zusammenfassung: {website_summary[:500]}") - if len(prompt_parts) <= 2: debug_print("Warnung evaluate_branche: Zu wenige Infos."); return {"branch": crm_branche, "consistency": "error_no_info", "justification": "Fehler: Zu wenige Informationen"} - prompt_parts.append("\nWICHTIG: Antworte NUR mit dem exakten Kurznamen einer Branche aus der obigen Liste. KEINE Präfixe.") - prompt_parts.append("\nAntworte ausschließlich im Format:") - prompt_parts.append("Branche: "); prompt_parts.append("Übereinstimmung: "); prompt_parts.append("Begründung: ") - prompt = "\n".join(prompt_parts) - chat_response = call_openai_chat(prompt, temperature=0.0) - if not chat_response: debug_print("Fehler evaluate_branche: Keine API Antwort."); return {"branch": crm_branche, "consistency": "error_api_no_response", "justification": "Fehler: Keine Antwort API"} - lines = chat_response.strip().split("\n"); result = {"branch": None, "consistency": None, "justification": ""}; suggested_branch = "" - for line in lines: - line_lower = line.lower() - if line_lower.startswith("branche:"): suggested_branch = line.split(":", 1)[1].strip().strip('"\'') - elif line_lower.startswith("begründung:"): result["justification"] = line.split(":", 1)[1].strip() - if not suggested_branch: debug_print(f"Fehler evaluate_branche: Parsing: {chat_response}"); return {"branch": crm_branche, "consistency": "error_parsing", "justification": f"Fehler: Parsing API Antwort. Antwort: {chat_response}"} - final_branch = None; suggested_branch_lower = suggested_branch.lower() - if suggested_branch_lower in allowed_branches_lookup: - final_branch = allowed_branches_lookup[suggested_branch_lower]; result["consistency"] = "pending_comparison" - debug_print(f"ChatGPT-Vorschlag '{suggested_branch}' gültig ('{final_branch}').") - else: - debug_print(f"ChatGPT-Vorschlag '{suggested_branch}' ungültig. Fallback...") - crm_short_branch = "k.A." - if crm_branche and ">" in crm_branche: crm_short_branch = crm_branche.split(">", 1)[1].strip() - elif crm_branche and crm_branche != "k.A.": crm_short_branch = crm_branche.strip() - if crm_short_branch != "k.A." and crm_short_branch.lower() in allowed_branches_lookup: - final_branch = allowed_branches_lookup[crm_short_branch.lower()] - result["consistency"] = "fallback_crm_valid" - fallback_reason = f"Fallback: Ungültiger ChatGPT-Vorschlag ('{suggested_branch}'). Gültige CRM-Kurzform '{final_branch}' verwendet." - result["justification"] = f"{fallback_reason} (ChatGPT: {result.get('justification', 'Keine')})" - debug_print(f"Fallback CRM erfolgreich: '{final_branch}'") - else: - final_branch = suggested_branch # Behalte ungültigen - result["consistency"] = "fallback_invalid" - error_reason = f"Fehler: Ungültiger ChatGPT ('{suggested_branch}') & ungültiger CRM Fallback ('{crm_short_branch}')." - result["justification"] = f"{error_reason} (ChatGPT: {result.get('justification', 'Keine')})" - debug_print(f"Fallback fehlgeschlagen. Ungültig: '{final_branch}', CRM: '{crm_short_branch}'") - result["branch"] = final_branch if final_branch else "FEHLER" - crm_short_to_compare = "k.A." - if crm_branche and ">" in crm_branche: crm_short_to_compare = crm_branche.split(">", 1)[1].strip() - elif crm_branche and crm_branche != "k.A.": crm_short_to_compare = crm_branche.strip() - if result["branch"] != "FEHLER" and result["branch"].lower() == crm_short_to_compare.lower(): - if result["consistency"] == "pending_comparison": result["consistency"] = "ok" - elif result["consistency"] == "pending_comparison": result["consistency"] = "X" - if result["consistency"] == "pending_comparison": result["consistency"] = "error_comparison_failed" - debug_print(f"Finale Branch-Evaluation: {result}") - return result -def load_target_schema(csv_filepath=BRANCH_MAPPING_FILE): # unverändert - global TARGET_SCHEMA_STRING, ALLOWED_TARGET_BRANCHES - allowed_branches_set = set(); debug_print(f"Lade Ziel-Schema aus '{csv_filepath}' Spalte A...") +# ==================== BRANCH MAPPING & SCHEMA ==================== + +def load_target_schema(csv_filepath=BRANCH_MAPPING_FILE): # Unverändert + """Lädt Liste erlaubter Ziele (Kurzformen) aus Spalte A der CSV.""" + global TARGET_SCHEMA_STRING, ALLOWED_TARGET_BRANCHES, BRANCH_MAPPING # BRANCH_MAPPING wird hier geleert + BRANCH_MAPPING = {} # Leeren, da nicht mehr für Mapping genutzt + allowed_branches_set = set() + debug_print(f"Versuche, Ziel-Schema (Kurzformen) aus '{csv_filepath}' Spalte A zu laden...") line_count = 0 try: with open(csv_filepath, encoding="utf-8-sig") as f: reader = csv.reader(f) + # Optional: Header überspringen + # next(reader, None) for row in reader: line_count += 1 # if line_count <= 10 or line_count % 100 == 0: debug_print(f"Schema-Laden: Lese Zeile {line_count}: {row}") - if len(row) >= 1: + if len(row) >= 1: # Nur Spalte A (Index 0) target = row[0].strip() - if target: allowed_branches_set.add(target) - # if line_count <= 10: debug_print(f" -> '{target}' hinzugefügt.") - except FileNotFoundError: debug_print(f"Fehler: Schema-Datei '{csv_filepath}' nicht gefunden."); ALLOWED_TARGET_BRANCHES = [] - except Exception as e: debug_print(f"Fehler beim Laden Schema '{csv_filepath}' (Zeile {line_count}): {e}"); ALLOWED_TARGET_BRANCHES = [] + if target: # Nur nicht-leere + allowed_branches_set.add(target) + # if line_count <= 10: debug_print(f" -> '{target}' zum Set hinzugefügt.") + except FileNotFoundError: + debug_print(f"Fehler: Schema-Datei '{csv_filepath}' nicht gefunden.") + ALLOWED_TARGET_BRANCHES = [] + except Exception as e: + debug_print(f"Fehler beim Laden des Ziel-Schemas aus '{csv_filepath}' (Zeile {line_count}): {e}") + ALLOWED_TARGET_BRANCHES = [] + ALLOWED_TARGET_BRANCHES = sorted(list(allowed_branches_set), key=str.lower) - debug_print(f"Ziel-Schema geladen: {len(ALLOWED_TARGET_BRANCHES)} Branchen.") + debug_print(f"Ziel-Schema geladen. {len(ALLOWED_TARGET_BRANCHES)} eindeutige Zielbranchen gefunden.") + if ALLOWED_TARGET_BRANCHES: - # debug_print(f"Erste 10 Zielbranchen: {ALLOWED_TARGET_BRANCHES[:10]}") + # debug_print(f"Erste 10 geladene Zielbranchen: {ALLOWED_TARGET_BRANCHES[:10]}") schema_lines = ["Ziel-Branchenschema: Folgende Branchenbereiche sind gültig (Kurzformen):"] 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. Gib NUR den Kurznamen zurück.") + schema_lines.append("Bitte ordne das Unternehmen ausschließlich in einen dieser Bereiche ein. Gib NUR den Kurznamen der Branche zurück (keine Präfixe wie 'Hersteller / Produzenten >').") TARGET_SCHEMA_STRING = "\n".join(schema_lines) - else: TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfügbar."; ALLOWED_TARGET_BRANCHES = [] + else: + TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfügbar (Datei leer oder Fehler)." + ALLOWED_TARGET_BRANCHES = [] + +def map_external_branch(external_branch): # Veraltet, da evaluate_branche_chatgpt genutzt wird + """ + Versucht, eine externe Branchenbezeichnung mithilfe des Mappings in das Ziel-Schema zu überführen. + (Diese Funktion wird aktuell nicht verwendet, da die Logik in evaluate_branche_chatgpt liegt) + """ + if not external_branch or not isinstance(external_branch, str) or not BRANCH_MAPPING: + return external_branch + norm_external = normalize_string(external_branch).lower() + if norm_external in BRANCH_MAPPING: return BRANCH_MAPPING[norm_external] + 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 Branche: '{key}' in '{norm_external}' -> '{BRANCH_MAPPING[key]}'") + return BRANCH_MAPPING[key] + # debug_print(f"Kein Mapping für externe Branche '{external_branch}' gefunden.") + return external_branch + + +# ==================== TOKEN COUNT FUNCTION ==================== @retry_on_failure -def token_count(text): # unverändert +def token_count(text): # Unverändert + """Zählt Tokens via tiktoken oder schätzt über Leerzeichen.""" if not text or not isinstance(text, str): return 0 if tiktoken: try: 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 Token-Counting tiktoken '{Config.TOKEN_MODEL}': {e}"); return len(text.split()) - else: return len(text.split()) + 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}") + return len(text.split()) # Fallback zur Schätzung + else: + return len(text.split()) # Fallback Schätzung -# --- GoogleSheetHandler (unverändert lassen) --- -class GoogleSheetHandler: # unverändert +# ==================== GOOGLE SHEET HANDLER ==================== +class GoogleSheetHandler: def __init__(self): - self.sheet = None; self.sheet_values = []; self.headers = [] - try: self._connect(); - except Exception as e: debug_print(f"FATAL GSheet Init: {e}"); raise ConnectionError(f"GSheet Handler Init failed: {e}") - if self.sheet: self.load_data() + """Initialisiert den Handler, verbindet und lädt initiale Daten.""" + self.sheet = None + self.sheet_values = [] + 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}") + raise ConnectionError(f"Google Sheet Handler Init failed: {e}") + @retry_on_failure def _connect(self): - self.sheet = None; debug_print("Verbinde mit Google Sheets...") + """Stellt Verbindung zum Google Sheet her.""" + self.sheet = None + 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 Google Sheets OK.") - except gspread.exceptions.APIError as e: debug_print(f"FEHLER Google API Verbindung: {e.response.status_code} - {e.response.text[:200]}"); raise e - except Exception as e: debug_print(f"FEHLER Google Sheets Verbindung: {type(e).__name__} - {e}"); raise e + 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 gspread.exceptions.APIError as e: + debug_print(f"FEHLER bei Google API Verbindung: Status {e.response.status_code} - {e.response.text[:200]}") + raise e + except Exception as e: + debug_print(f"FEHLER bei der Google Sheets Verbindung: {type(e).__name__} - {e}") + raise e + @retry_on_failure def load_data(self): - if not self.sheet: debug_print("Fehler: Keine Sheet-Verbindung für load_data."); self.sheet_values = []; self.headers = []; return False - debug_print("Lade Daten aus Google Sheet..."); + """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 + debug_print("Lade Daten aus Google Sheet...") try: self.sheet_values = self.sheet.get_all_values() - if not self.sheet_values: debug_print("Warnung: Sheet leer."); self.headers = []; return True + if not self.sheet_values: + debug_print("Warnung: Google Sheet scheint leer zu sein.") + self.headers = []; return True # Leer ist kein Fehler + # Setze Header nur, wenn Daten vorhanden if len(self.sheet_values) >= 1: self.headers = self.sheet_values[0] else: self.headers = [] - debug_print(f"Daten neu geladen: {len(self.sheet_values)} Zeilen."); return True - except gspread.exceptions.APIError as e: debug_print(f"Google API Fehler Laden: {e.response.status_code} - {e.response.text[:200]}"); raise e - except Exception as e: debug_print(f"Allg. Fehler Laden: {e}"); raise e + debug_print(f"Daten neu geladen: {len(self.sheet_values)} Zeilen insgesamt.") + return True + 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 + except Exception as e: + debug_print(f"Allgemeiner Fehler beim Laden der Google Sheet Daten: {e}") + raise e + def get_data(self): + """Gibt die aktuell im Handler gespeicherten Daten zurück (ohne Header).""" + # Nutzt Config.HEADER_ROWS if not self.sheet_values or len(self.sheet_values) <= Config.HEADER_ROWS: - if self.sheet_values: debug_print(f"Warnung get_data: Nur {len(self.sheet_values)} Zeilen.") + if self.sheet_values: + debug_print(f"Warnung in get_data: Nur {len(self.sheet_values)} Zeilen vorhanden, weniger als {Config.HEADER_ROWS} Header erwartet.") return [] return self.sheet_values[Config.HEADER_ROWS:] + def get_all_data_with_headers(self): - if not self.sheet_values: debug_print("Warnung get_all_data_with_headers: Keine Daten."); 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.") + return [] return self.sheet_values + def _get_col_letter(self, col_idx_1_based): + """ Konvertiert 1-basierten Spaltenindex in Buchstaben (A, B, ..., Z, AA, ...). """ 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 + while n > 0: + n, remainder = divmod(n - 1, 26) + string = chr(65 + remainder) + string return string + 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, in der der Wert in der Spalte EXAKT LEER ("") ist. + Lädt Daten neu. + """ if not self.load_data(): return -1 - data_rows = self.get_data() - if not data_rows: return 0 + # Nutzt Config.HEADER_ROWS + data_rows = self.get_data() # Holt Daten ohne Header + if not data_rows: return 0 # Wenn keine Daten (nur Header), starte bei Index 0 + check_column_index = COLUMN_MAP.get(check_column_key) - if check_column_index is None: debug_print(f"FEHLER: Key '{check_column_key}' nicht in COLUMN_MAP!"); return -1 + if check_column_index is None: + debug_print(f"FEHLER: Schlüssel '{check_column_key}' nicht in COLUMN_MAP gefunden!") + return -1 + actual_col_letter = self._get_col_letter(check_column_index + 1) + # Berechne Startindex relativ zur data_rows Liste search_start_index_in_data = max(0, min_sheet_row - Config.HEADER_ROWS - 1) - debug_print(f"get_start_row_index: Suche ab Daten-Idx {search_start_index_in_data} nach LEER ('') in '{check_column_key}' ({actual_col_letter})...") - if search_start_index_in_data >= len(data_rows): debug_print(f"Start-Suchindex >= Datenlänge."); return len(data_rows) + + debug_print(f"get_start_row_index: Suche ab Daten-Index {search_start_index_in_data} 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 vorherigen Zeilen scheinen gefüllt.") + return len(data_rows) # Signalisiert, dass am Ende begonnen werden soll + for i in range(search_start_index_in_data, len(data_rows)): - row = data_rows[i]; current_sheet_row = i + Config.HEADER_ROWS + 1 + row = data_rows[i] + current_sheet_row = i + Config.HEADER_ROWS + 1 cell_value = ""; is_exactly_empty = True - if len(row) > check_column_index: cell_value = row[check_column_index]; - if cell_value != "": is_exactly_empty = False - # log_debug = (i == search_start_index_in_data or i % 1000 == 0 or is_exactly_empty or i in range(10110, 10116)) - # if log_debug: debug_print(f" -> Prüfe Daten-Idx {i} (Sheet {current_sheet_row}): Wert {actual_col_letter}='{cell_value}'. Leer? {is_exactly_empty}") + if len(row) > check_column_index: + cell_value = row[check_column_index] + if cell_value != "": is_exactly_empty = False + # Reduziertes Logging + # log_debug = (i == search_start_index_in_data or i % 1000 == 0 or is_exactly_empty) + # if log_debug: debug_print(f" -> Prüfe Daten-Index {i} (Sheet {current_sheet_row}): Wert in {actual_col_letter}='{cell_value}'. Ist leer? {is_exactly_empty}") if is_exactly_empty: - debug_print(f"Erste Zeile ab {min_sheet_row} mit LEEREM Wert in {actual_col_letter} gefunden: Zeile {current_sheet_row} (Daten-Index {i})") - return i + 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 0-basierten Index für data_rows zurück + + # Wenn die Schleife durchläuft, wurde keine leere Zelle gefunden last_index = len(data_rows) - debug_print(f"Alle Zeilen ab Daten-Idx {search_start_index_in_data} nicht leer in {actual_col_letter}. Nächster Idx {last_index}.") - return last_index + 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 # Nächster Index nach der letzten Zeile + @retry_on_failure def batch_update_cells(self, update_data): - if not self.sheet: debug_print("FEHLER: Keine Sheet-Verbindung für Batch-Update."); return False - if not update_data: return True + """ Führt ein Batch-Update im Google Sheet durch. """ + if not self.sheet: + debug_print("FEHLER: Keine Sheet-Verbindung für Batch-Update.") + return False + if not update_data: return True # Nichts zu tun ist Erfolg + success = False try: - # debug_print(f" -> Versuche sheet.batch_update mit {len(update_data)} Operationen...") # Weniger Lärm + # debug_print(f" -> Versuche sheet.batch_update mit {len(update_data)} Operationen...") self.sheet.batch_update(update_data, value_input_option='USER_ENTERED') success = True + # debug_print(f" -> sheet.batch_update erfolgreich.") # Log in aufrufender Funktion except gspread.exceptions.APIError as e: - debug_print(f" -> FEHLER (Google API Error) Batch-Update: Status {e.response.status_code}") + debug_print(f" -> FEHLER (Google API Error) beim Batch-Update: Status {e.response.status_code}") try: error_details = e.response.json(); debug_print(f" -> Details: {str(error_details)[:500]}") except: debug_print(f" -> Raw Response Text: {e.response.text[:500]}") - raise e + raise e # Damit retry greift except Exception as e: - debug_print(f" -> FEHLER (Allgemein) Batch-Update: {type(e).__name__} - {e}") - import traceback; debug_print(traceback.format_exc()) - raise e + debug_print(f" -> FEHLER (Allgemein) beim Batch-Update: {type(e).__name__} - {e}") + debug_print(traceback.format_exc()) + raise e # Damit retry greift return success -# ==================== WIKIPEDIA SCRAPER (MODIFIZIERT) ==================== +# ==================== WIKIPEDIA SCRAPER ==================== class WikipediaScraper: + # KEINE Fallback-Methode hier in v1.6.5, nur Anpassungen an _extract_infobox_value def __init__(self): try: wikipedia.set_lang(Config.LANG) - except Exception as e: debug_print(f"Fehler Setzen Wikipedia-Sprache: {e}") + except Exception as e: debug_print(f"Fehler beim Setzen der Wikipedia-Sprache: {e}") def _get_full_domain(self, website): # unverändert if not website: return ""; website = website.lower().strip() @@ -701,38 +660,23 @@ class WikipediaScraper: else: debug_print(f" => Nicht validiert (Schwelle: {threshold:.2f})") return is_valid - def _extract_first_paragraph_from_soup(self, soup): # MODIFIZIERT: Logging hinzugefügt + def _extract_first_paragraph_from_soup(self, soup): # Mit Logging aus v1.6.5 if not soup: return "k.A." - # Suche nach dem Hauptinhaltsbereich content_div = soup.find('div', class_='mw-parser-output') - if not content_div: - content_div = soup.find('div', id='bodyContent') # Fallback - if not content_div: - content_div = soup # Fallback auf ganzen Soup + if not content_div: content_div = soup.find('div', id='bodyContent') + if not content_div: content_div = soup - # Finde alle

-Tags direkt unterhalb des content_div - # 'recursive=False' versucht, tiefer verschachtelte

(z.B. in Tabellen) zu vermeiden paragraphs = content_div.find_all('p', recursive=False) - if not paragraphs: - paragraphs = content_div.find_all('p', recursive=True) # Fallback: Alle

- - debug_print(f" Absatz-Extraktion: {len(paragraphs)}

-Tags gefunden (im Bereich {content_div.name if content_div != soup else 'soup'}).") + if not paragraphs: paragraphs = content_div.find_all('p', recursive=True) + debug_print(f" Absatz-Extraktion: {len(paragraphs)}

-Tags gefunden (in {content_div.name if content_div != soup else 'soup'}).") for idx, p in enumerate(paragraphs): - # Ignoriere

innerhalb von Infoboxen oder anderen speziellen Containern - if p.find_parent(['table', 'aside', 'figure', 'div.thumb', 'div.gallery']): - # debug_print(f" ->

{idx} übersprungen (in table/aside etc.)") - continue - + if p.find_parent(['table', 'aside', 'figure', 'div.thumb', 'div.gallery']): continue text = clean_text(p.get_text()) - debug_print(f" -> Prüfe

{idx}: Text='{text[:100]}...' (Länge: {len(text)})") - - # Nimm den ersten Absatz mit signifikanter Länge (mind. 50 Zeichen) - # und der nicht nur aus Koordinaten etc. besteht + # debug_print(f" -> Prüfe

{idx}: Text='{text[:100]}...' (Länge: {len(text)})") if len(text) > 50 and not text.startswith("Koordinaten:"): - debug_print(f" --> Erster signifikanter Absatz gefunden: '{text[:100]}...'") - return text[:1000] # Begrenze Länge - + debug_print(f" --> Erster signifikanter Absatz gefunden.") + return text[:1000] debug_print(" -> Kein signifikanter erster Absatz gefunden.") return "k.A." @@ -746,37 +690,37 @@ class WikipediaScraper: return ", ".join(cats) if cats else "k.A." return "k.A." - def _extract_infobox_value(self, soup, target): # MODIFIZIERT: Mehr Keywords, Logging HTML + def _extract_infobox_value(self, soup, target): # MODIFIZIERT: Logging HTML, erweiterte Keywords if not soup: return "k.A." - infobox = soup.find('table', class_=lambda c: c and any(kw in c.lower() for kw in ['infobox', 'vcard', 'unternehmen', 'konzern', 'organisation'])) # Flexiblere Suche + # Flexiblere Suche nach Infobox-Klassen + infobox = soup.find('table', class_=lambda c: c and any(kw in c.lower() for kw in ['infobox', 'vcard', 'unternehmen', 'konzern', 'organisation'])) if not infobox: debug_print(f" -> Infobox-Extraktion ('{target}'): Keine Infobox Tabelle gefunden.") return "k.A." - # --- NEU: Logge das HTML der gefundenen Infobox --- + # Logge das HTML der gefundenen Infobox für Debugging try: infobox_html = str(infobox) debug_print(f" -> Infobox HTML gefunden (Auszug):\n------ INFOBOX HTML START -----\n{infobox_html[:1000]}...\n------ INFOBOX HTML END ------") except Exception as log_e: debug_print(f" -> Fehler beim Loggen des Infobox HTML: {log_e}") - # --- Ende HTML Logging --- # Erweiterte Keywords (Deutsch & Englisch, Variationen) keywords_map = { 'branche': [ 'branche', 'branchen', 'industrie', 'tätigkeit', 'geschäftsfeld', 'sektor', - 'produkte', 'leistungen', 'aktivitäten', 'wirtschaftszweig', - 'industry', 'sector', 'business', 'products', 'services', 'field' + 'produkte', 'leistungen', 'aktivitäten', 'wirtschaftszweig', 'produktpalette', + 'industry', 'sector', 'business', 'products', 'services', 'field', 'area' ], 'umsatz': [ 'umsatz', 'jahresumsatz', 'konzernumsatz', 'gesamtumsatz', 'erlöse', 'umsatzerlöse', - 'einnahmen', 'ergebnis', 'jahresergebnis', 'umsatz pro jahr', - 'revenue', 'turnover', 'sales', 'income', 'earnings', 'annual revenue' + 'einnahmen', 'ergebnis', 'jahresergebnis', 'umsatz pro jahr', 'geschäftsvolumen', + 'revenue', 'turnover', 'sales', 'income', 'earnings', 'annual revenue', 'gross profit' ], 'mitarbeiter': [ 'mitarbeiter', 'mitarbeiterzahl', 'beschäftigte', 'personal', 'angestellte', 'belegschaft', 'personalstärke', 'kopfzahl', 'mitarbeitende', 'anzahl mitarbeiter', - 'employees', 'number of employees', 'staff', 'headcount', 'workforce' + 'employees', 'number of employees', 'staff', 'headcount', 'workforce', 'personnel' ] } keywords = keywords_map.get(target, []) @@ -789,18 +733,15 @@ class WikipediaScraper: value_cell = row.find('td') if header and value_cell: - # Hole Text aus th, ignoriere versteckte Elemente (z.B. in