From 2a2c8b0e504f56bececc2092f8ab7a99b2de25bd Mon Sep 17 00:00:00 2001 From: Floke Date: Tue, 6 May 2025 12:30:09 +0000 Subject: [PATCH] bugfix --- brancheneinstufung.py | 198 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 163 insertions(+), 35 deletions(-) diff --git a/brancheneinstufung.py b/brancheneinstufung.py index b1d118cb..8f5af975 100644 --- a/brancheneinstufung.py +++ b/brancheneinstufung.py @@ -252,7 +252,7 @@ decorator_logger = logging.getLogger(__name__ + ".Retry") # --- Retry Decorator --- -# KORRIGIERTE Version (Behandelt SpreadsheetNotFound und 404 HTTPError explizit) +# KORRIGIERTE Version (Behandelt SpreadsheetNotFound und 404/400 HTTPError explizit) def retry_on_failure(func): """ Decorator, der eine Funktion bei bestimmten Fehlern mehrmals wiederholt. @@ -292,8 +292,7 @@ def retry_on_failure(func): return func(*args, **kwargs) # Call the original function - # Spezifische Exceptions, die ein Retry rechtfertigen - # Fangen Sie nicht-wiederholbare Fehler separat + # Spezifische Exceptions, die ein Retry nicht rechtfertigen (permanente Fehler) except (gspread.exceptions.SpreadsheetNotFound, openai.error.AuthenticationError, ValueError) as e: # Diese Fehler deuten auf ein permanentes Problem hin (falsche URL, falscher Key, falsche Eingabe) decorator_logger.critical(f"❌ ENDGÜLTIGER FEHLER bei '{effective_func_name}': Permanentes Problem erkannt. {type(e).__name__} - {str(e)[:150]}...") @@ -302,9 +301,12 @@ def retry_on_failure(func): # Fangen Sie Requests HTTP Errors (wie 404) except requests.exceptions.HTTPError as e: - if e.response is not None: + if hasattr(e, 'response') and e.response is not None: status_code = e.response.status_code - if status_code in [404, 400]: # Fügen Sie hier weitere Status-Codes hinzu, die NICHT wiederholt werden sollen + # Definieren Sie hier eine Liste von Status-Codes, die NICHT wiederholt werden sollen + non_retryable_status_codes = [404, 400, 401, 403] # Not Found, Bad Request, Unauthorized, Forbidden + + if status_code in non_retryable_status_codes: decorator_logger.critical(f"❌ ENDGÜLTIGER FEHLER bei '{effective_func_name}': HTTP Fehler {status_code} erhalten ({e.response.reason}). Nicht wiederholbar. {str(e)[:150]}...") decorator_logger.exception("Details:") # Log traceback raise e # Leiten Sie diese nicht-wiederholbare Exception sofort weiter @@ -338,8 +340,6 @@ def retry_on_failure(func): time.sleep(wait_time) # Warte vor dem nächsten Versuch else: # Letzter Versuch fehlgeschlagen decorator_logger.error(f"❌ ENDGÜLTIGER FEHLER bei '{effective_func_name}' nach {max_retries_config} Versuchen.") - # Log traceback für erwartete Fehler beim endgültigen Scheitern - # decorator_logger.exception("Details zum endgültigen Fehler:") # Kann zu viel Lärm machen raise e # Leite die ursprüngliche Exception weiter except Exception as e: @@ -351,8 +351,6 @@ def retry_on_failure(func): # Dieser Teil sollte theoretisch nicht erreicht werden, wenn max_retries_config > 0 # und eine Exception immer zu einer raise e Anweisung führt. - # Wenn die Schleife endet, ohne zurückzukehren oder eine Exception zu werfen, - # deutet dies auf einen Logikfehler im Decorator hin. raise RuntimeError(f"Retry decorator logic error: Loop completed unexpectedly for {effective_func_name}. This should not happen.") @@ -815,6 +813,7 @@ def load_target_schema(csv_filepath=BRANCH_MAPPING_FILE): TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfügbar (Keine gültigen Branchen in Datei gefunden)." logger.warning("Keine gültigen Zielbranchen im Schema gefunden. Branchenbewertung ist nicht möglich.") + # map_external_branch ist in dieser Struktur nicht mehr notwendig, # da die Branchenevaluation über ChatGPT (evaluate_branche_chatgpt) # direkt gegen ALLOWED_TARGET_BRANCHES validiert. @@ -1490,9 +1489,10 @@ def search_linkedin_contacts(company_name, website, position_query, crm_kurzform # --- Experimentelle Website Details Scraping Funktion --- -# Diese Funktion wurde in DataProcessor.process_website_details aufgerufen +# Diese Funktion wurde in DataProcessor.process_website_details aufgerufen. +# Sie ist hier global platziert, da sie nicht spezifisch von DataProcessor state abhängt, +# sondern nur von globalen Helfern und Requests. # Ihre Implementierung hängt stark von der Struktur der Zielwebsites ab. -# HIER ist ein Platzhalter für diese Funktion. Sie MUSS implementiert werden. def scrape_website_details(url): """ EXPERIMENTELL: Scrapt eine Website und extrahiert spezifische Details. @@ -1504,14 +1504,10 @@ def scrape_website_details(url): Returns: str: Extrahierte Details als String oder Fehler/k.A. """ - logger.warning(f"Platzhalter Funktion 'scrape_website_details' für URL {url} aufgerufen.") + logger.warning(f"Ausführe 'scrape_website_details' für URL {url}.") # Beispiel: Einfaches Abrufen des Tags try: - # Nutzt get_page_soup aus der WikipediaScraper Klasse, aber das ist eine Methode. - # Wir brauchen hier eine separate Funktion oder eine Instanz. - # Oder wir verschieben diese Logik in die DataProcessor Klasse, die den Scraper hat. - # Am besten: Lassen Sie diese Funktion global, aber nutzen Sie requests direkt oder eine Hilfsfunktion. - # Nutzen wir requests direkt mit retry_on_failure + # Hilfsfunktion zum Abrufen des Soup-Objekts mit Retry @retry_on_failure def get_soup_for_details(target_url): response = requests.get(target_url, timeout=getattr(Config, 'REQUEST_TIMEOUT', 15)) @@ -1527,6 +1523,7 @@ def scrape_website_details(url): h1 = soup.find('h1') details_list = [] + # clean_text nutzt globale Funktion if title: details_list.append(f"Title: {clean_text(title.get_text())}") if meta_desc and meta_desc.get('content'): details_list.append(f"Description: {clean_text(meta_desc['content'])}") if h1: details_list.append(f"H1: {clean_text(h1.get_text())}") @@ -1537,13 +1534,144 @@ def scrape_website_details(url): return "k.A. (Keine Standard-Details gefunden)" else: - return "k.A. (Scraping fehlgeschlagen)" # Fehler wurde bereits geloggt + # Fehler wurde bereits in get_soup_for_details oder retry geloggt + return "k.A. (Scraping fehlgeschlagen)" except Exception as e: # retry_on_failure wirft am Ende Exception + # Dieser Fehler wird bereits vom retry_on_failure geloggt logger.error(f"FEHLER in scrape_website_details für {url}: {e}") return f"FEHLER: {str(e)[:100]}" # Rückgabe der Fehlermeldung +# --- Globale Funktion zum Scrapen des Website Rohtextes --- +# Übernommen aus get_website_raw in Teil 7. Global platziert. +@retry_on_failure +def get_website_raw(url, max_length=20000, verify_cert=True): # Längeres Default Limit, SSL-Zertifikat standardmäßig prüfen + """ + Holt Textinhalt von einer Website, versucht Cookie-Banner zu umgehen. + Gibt den Rohtext zurück oder einen Fehlerwert ("k.A.", "k.A. (Fehler)", etc.). + """ + if not url or not isinstance(url, str) or url.strip().lower() in ["k.a.", "kein artikel gefunden", "fehler bei suche"]: + logger.debug(f"get_website_raw skipped: Ungültige oder leere URL '{url}'.") + return "k.A." + + # Falls kein Schema vorhanden ist, hinzufügen (HTTPS bevorzugen) + if not url.lower().startswith(("http://", "https://")): + #logger.debug(f"Kein Schema in URL '{url}', füge https:// hinzu.") # Zu viel Lärm + url = "https://" + url + + # Verwenden Sie eine Requests Session oder requests direkt. + # Eine Session in DataProcessor könnte besser sein, aber globale Funktion nutzt requests direkt. + headers = { + "User-Agent": getattr(Config, 'USER_AGENT', 'Mozilla/5.0 (compatible; UnternehmenSkript/1.0; +http://www.example.com/bot)') # Nutzt Config oder Fallback + } + + try: + # Der Requests Call wird vom retry_on_failure Decorator behandelt. + # Timeout sollte aus Config kommen. + response = requests.get(url, timeout=getattr(Config, 'REQUEST_TIMEOUT', 15), headers=headers, verify=verify_cert) + response.raise_for_status() # Wirft HTTPError für 4xx/5xx Antworten. Wird vom Decorator gefangen. + + # Versuche, das Encoding aus dem Header oder dem Content zu erraten + response.encoding = response.apparent_encoding + + soup = BeautifulSoup(response.text, getattr(Config, 'HTML_PARSER', 'html.parser')) # Nutzt Config oder Fallback + + # --- Versuch 1: Hauptinhalt-Tags finden --- + # Verwenden Sie eine Liste von Selektoren + content_selectors = [ + 'main', 'article', '#content', '#main-content', '.main-content', '.content', + 'div[role="main"]', 'div.page-content', 'div.container' # Weitere gängige Selektoren + ] + content_area = None + for selector in content_selectors: + content_area = soup.select_one(selector) + if content_area: + #logger.debug(f"Gezielten Inhaltsbereich gefunden mit Selektor '{selector}' für {url}.") # Zu viel Lärm + break # Ersten gefundenen Bereich nehmen + + if not content_area: + # --- Fallback: Body nehmen, ABER Banner versuchen zu entfernen --- + #logger.debug(f"Kein spezifischer Inhaltsbereich gefunden für {url}. Nutze Body und versuche Banner zu entfernen.") # Zu viel Lärm + content_area = soup.find('body') + + if content_area: + # Versuche, häufige Cookie-Banner Strukturen zu entfernen + # Diese Selektoren sollten angepasst werden, wenn spezifische Banner Probleme machen + banner_selectors = [ + '[id*="cookie"]', '[class*="cookie"]', '[id*="consent"]', '[class*="consent"]', + '.cookie-banner', '.consent-banner', '.modal', '#modal', '.popup', '#popup', + '[role="dialog"]', '[aria-modal="true"]' + ] + banners_removed_count = 0 + # Gehe rückwärts durch die gefundenen Elemente, um Decompose sicher zu machen + for selector in banner_selectors: + try: + # select findet alle passenden Elemente + potential_banners = content_area.select(selector) + for banner in potential_banners: + # Zusätzliche Prüfung: Enthält das Element typischen Banner-Text? + # Vermeiden Sie das Entfernen von echtem Inhalt, der zufällig das Wort "cookie" enthält. + banner_text = banner.get_text(" ", strip=True).lower() + keywords = ["cookie", "zustimm", "ablehnen", "einverstanden", "datenschutz", "privacy", "akzeptier", "einstellung", "partner", "analyse", "marketing"] + # Prüfe, ob ein Keyword im Text ODER im class/id Namen vorkommt + if any(keyword in banner_text for keyword in keywords) or any(keyword in (banner.get('id', '') + banner.get('class', '')).lower() for keyword in keywords): + #logger.debug(f"Entferne potenzielles Banner ({selector}) mit Text: {banner_text[:100]}...") # Zu viel Lärm + banner.decompose() # Entferne das Element aus dem Baum + banners_removed_count += 1 + except Exception as e_select: + # Logge Fehler bei der Banner-Entfernung, aber fahre fort + logger.debug(f"Fehler beim Versuch Banner mit Selektor '{selector}' zu entfernen: {e_select}") + if banners_removed_count > 0: + logger.debug(f"{banners_removed_count} potenzielle Banner-Elemente für {url} entfernt.") + + # --- Text extrahieren aus gefundenem Bereich (oder Body) --- + if content_area: + # Entferne Skripte und Styles, bevor der Text extrahiert wird + for script_or_style in content_area(["script", "style"]): + script_or_style.decompose() + + # Extrahiere Text mit Leerzeichen als Trenner + text = content_area.get_text(separator=' ', strip=True) + text = re.sub(r'\s+', ' ', text).strip() # Normalisiere und trimme Whitespace + + # --- Zusätzliche Prüfung: Ist der extrahierte Text *nur* Banner-Text? --- + # Diese Heuristik ist eine Fallback-Maßnahme, wenn die Decompose-Logik nicht perfekt war. + banner_keywords_strict = ["cookie", "zustimmen", "ablehnen", "einverstanden", "datenschutz", "privacy", "akzeptier", "einstellung", "partner", "analyse", "marketing"] + text_lower = text.lower() + keyword_hits = sum(1 for keyword in banner_keywords_strict if keyword in text_lower) + + # Heuristik: Wenn der Text kurz ist UND viele Banner-Keywords enthält -> Verwerfen + # Passen Sie die Schwellenwerte an + if len(text) < 500 and keyword_hits >= 3: # Wenn Text kürzer als 500 Zeichen und >= 3 Keywords + logger.warning(f"WARNUNG: Extrahierter Text für {url} scheint nur Cookie-Banner zu sein (Länge {len(text)}, {keyword_hits} Keywords). Verwerfe Text.") + return "k.A. (Nur Cookie-Banner erkannt)" + + # Wenn der Text nach Bereinigung immer noch sehr kurz ist (z.B. nur ein paar Worte) + if len(text.split()) < 10 or len(text) < 50: + #logger.debug(f"Extrahierter Text für {url} ist sehr kurz ({len(text.split())} Worte, {len(text)} Zeichen).") # Zu viel Lärm + # Kann immer noch valide sein, aber ist oft kein relevanter Inhalt. + # Geben wir ihn trotzdem zurück, gekürzt. + + pass # Behalte den Text, keine weitere Filterung + + # Begrenzen Sie die Länge des zurückgegebenen Rohtextes + result = text[:max_length] + logger.debug(f"Website {url} erfolgreich gescrapt. Extrahierter Text (Länge {len(result)}).") + # logger.debug(f"Extrahierter Text Anfang: {result[:100]}...") # Zu viel Lärm + return result if result else "k.A. (Extraktion leer)" # Rückgabe des gekürzten Textes + + else: + logger.warning(f"Kein <body> oder spezifischer Inhaltsbereich gefunden in {url}.") + return "k.A. (Kein Body gefunden)" + + # Exceptions (wie RequestsErrors) werden vom retry_on_failure Decorator behandelt. + # Wenn eine Exception hier durchkommt, hat der Decorator aufgegeben. + except Exception as e: # Fangen Sie alle verbleibenden Exceptions, die nicht vom Decorator behandelt wurden + logger.error(f"Allgemeiner Fehler beim Scraping von {url}: {type(e).__name__} - {e}") + # Die Exception wurde bereits vom Decorator geloggt + return f"k.A. (Fehler: {str(e)[:100]}...)" # Signalisiert Fehler + # TODO: Weitere globale Helferfunktionen (z.B. für FSM, Emp, Umsatz Schätzung Prompts und Parsing) müssen hier implementiert werden, # falls sie nicht in den DataProcessor integriert wurden. Platzhalter wurden in DataProcessor._process_single_row hinzugefügt. @@ -3659,7 +3787,7 @@ class DataProcessor: # process_contact_search method... (kommt in Teil 13) # process_wiki_updates_from_chatgpt method... (kommt in Teil 14) # process_wiki_reextract_missing_an method... (kommt in Teil 14) -# ========================================================================== + # ========================================================================== # === Batch Processing Methods ============================================= # ========================================================================== @@ -4133,22 +4261,22 @@ class DataProcessor: # --- Worker-Funktion für Scraping --- # Diese Funktion läuft in einem separaten Thread - def scrape_raw_text_task(task_info): - row_num = task_info['row_num'] - url = task_info['url'] - raw_text = "k.A." - error = None - try: - # Nutzt die globale Funktion get_website_raw mit Retry Decorator - raw_text = get_website_raw(url) # Annahme: get_website_raw in utils.py - except Exception as e: - # Fängt Fehler beim Scraping, damit der Thread nicht abstürzt - error = f"Scraping Fehler Zeile {row_num} ({url}): {e}" - self.logger.error(error) - raw_text = "k.A. (Fehler)" # Setze einen Fehlerwert in den Rohtext + # def scrape_raw_text_task(task_info): + # row_num = task_info['row_num'] + # url = task_info['url'] + # raw_text = "k.A." + # error = None + # try: + # # Nutzt die globale Funktion get_website_raw mit Retry Decorator + # raw_text = get_website_raw(url) # Annahme: get_website_raw in utils.py + # except Exception as e: + # # Fängt Fehler beim Scraping, damit der Thread nicht abstürzt + # error = f"Scraping Fehler Zeile {row_num} ({url}): {e}" + # self.logger.error(error) + # raw_text = "k.A. (Fehler)" # Setze einen Fehlerwert in den Rohtext #logger.debug(f"Scraping Task Zeile {row_num} abgeschlossen. Textlänge: {len(str(raw_text))}.") # Zu viel Lärm - return {"row_num": row_num, "raw_text": raw_text, "error": error} + # return {"row_num": row_num, "raw_text": raw_text, "error": error} # --- Hauptlogik: Iteriere und sammle Batches --- @@ -4226,7 +4354,7 @@ class DataProcessor: # Nutzt concurrent.futures für paralleles Scraping with concurrent.futures.ThreadPoolExecutor(max_workers=max_scraping_workers) as executor: # Map tasks to futures - future_to_task = {executor.submit(scrape_raw_text_task, task): task for task in tasks_for_processing_batch} + future_to_task = {executor.submit(_scrape_raw_text_task_global, task): task for task in tasks_for_processing_batch} # Auf globalen Namen geändert # Process results as they complete for future in concurrent.futures.as_completed(future_to_task): @@ -5493,7 +5621,7 @@ class DataProcessor: # process_website_details method... (kommt in Teil 16) # Optional/Experimentell # process_wiki_updates_from_chatgpt method... (kommt in Teil 16) # process_wiki_reextract_missing_an method... (kommt in Teil 16) -# ========================================================================== + # ========================================================================== # === Utility Methods (ML Data Prep & Training) ============================ # ==========================================================================