This commit is contained in:
2025-05-06 12:30:09 +00:00
parent 86c14f264a
commit 2a2c8b0e50

View File

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