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 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) ============================
# ==========================================================================