bugfix
This commit is contained in:
@@ -252,7 +252,7 @@ decorator_logger = logging.getLogger(__name__ + ".Retry")
|
||||
|
||||
|
||||
# --- Retry Decorator ---
|
||||
# KORRIGIERTE Version
|
||||
# KORRIGIERTE Version (Behandelt SpreadsheetNotFound explizit)
|
||||
def retry_on_failure(func):
|
||||
"""
|
||||
Decorator, der eine Funktion bei bestimmten Fehlern mehrmals wiederholt.
|
||||
@@ -276,7 +276,7 @@ def retry_on_failure(func):
|
||||
except Exception as e:
|
||||
# Fehler loggen und weitergeben, wenn keine Retries konfiguriert sind
|
||||
decorator_logger.error(f"FEHLER bei '{effective_func_name}' (keine Retries konfiguriert). {type(e).__name__} - {str(e)[:150]}...")
|
||||
# Log traceback für unerwartete Fehler
|
||||
# Log traceback für unerwartete Fehler (nicht die spezifischen API/Netzwerkfehler)
|
||||
if not isinstance(e, (requests.exceptions.RequestException, gspread.exceptions.APIError, openai.error.OpenAIError, wikipedia.exceptions.WikipediaException)):
|
||||
decorator_logger.exception("Details zum Fehler:")
|
||||
raise e # Re-raise the exception
|
||||
@@ -293,7 +293,15 @@ def retry_on_failure(func):
|
||||
return func(*args, **kwargs) # Call the original function
|
||||
|
||||
# Spezifische Exceptions, die ein Retry rechtfertigen
|
||||
except (requests.exceptions.RequestException, gspread.exceptions.APIError, openai.error.OpenAIError, wikipedia.exceptions.WikipediaException) as e:
|
||||
# Fangen Sie SpreadsheetNotFound separat, da kein Retry sinnvoll ist
|
||||
except gspread.exceptions.SpreadsheetNotFound as e:
|
||||
decorator_logger.critical(f"❌ ENDGÜLTIGER FEHLER bei '{effective_func_name}': Google Sheet nicht gefunden oder keine Berechtigung (<Response [404]>).")
|
||||
# Log traceback nur für dieses kritische Problem
|
||||
decorator_logger.exception("Details:")
|
||||
raise e # Leiten Sie diese nicht-wiederholbare Exception sofort weiter
|
||||
|
||||
# Andere spezifische Exceptions, die ein Retry rechtfertigen
|
||||
except (gspread.exceptions.APIError, requests.exceptions.RequestException, openai.error.OpenAIError, wikipedia.exceptions.WikipediaException) as e:
|
||||
error_msg = str(e)
|
||||
error_type = type(e).__name__
|
||||
|
||||
@@ -317,7 +325,6 @@ def retry_on_failure(func):
|
||||
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
|
||||
# Log traceback nur bei unerwarteten Fehlern (nicht den, der zum Retry führte)
|
||||
# decorator_logger.exception("Details zum endgültigen Fehler:") # Kann zu viel Lärm machen
|
||||
raise e # Leite die ursprüngliche Exception weiter
|
||||
|
||||
@@ -330,20 +337,12 @@ 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.
|
||||
# Als Schutzmechanismus werfen wir hier eine RuntimeError, falls dieser Punkt doch erreicht wird.
|
||||
# Oder geben None zurück, falls das erwartete Verhalten bei endgültigem Fehler None ist.
|
||||
# Angesichts der Tatsache, dass die aufgerufenen Funktionen oft None bei Fehler zurückgeben,
|
||||
# geben wir hier None zurück, anstatt eine RuntimeError zu werfen.
|
||||
# Dies erfordert, dass die aufgerufene Funktion im Falle eines Fehlers IMMER eine Exception wirft,
|
||||
# die vom Decorator gefangen und (beim letzten Versuch) weitergeleitet wird.
|
||||
# Wenn die dekorierte Funktion selbst interne Fehler fängt und None zurückgibt,
|
||||
# wird der Decorator dies als erfolgreichen Durchlauf interpretieren und None zurückgeben.
|
||||
# Das ist das erwartete Verhalten.
|
||||
# Entfernen Sie die RuntimeError und lassen Sie die Funktion implizit None zurückgeben,
|
||||
# wenn der try/except/raise Block nicht erreicht wurde.
|
||||
pass # Dies sollte nicht erreicht werden, wenn eine Exception geworfen wird.
|
||||
# Geben Sie None zurück, falls die dekorierte Funktion dies im Erfolgsfall tun könnte.
|
||||
# Da wir erwarten, dass die dekorierte Funktion im Erfolgsfall einen Wert oder None zurückgibt
|
||||
# und im Fehlerfall eine Exception wirft, sollte dieser Punkt nicht erreicht werden,
|
||||
# wenn ein Fehler auftritt.
|
||||
# Wenn die Schleife ohne Fehler endet, wurde das Ergebnis bereits zurückgegeben.
|
||||
pass # Dies sollte nicht erreicht werden
|
||||
|
||||
# Die wrapper Funktion muss am Ende zurückgegeben werden
|
||||
return wrapper
|
||||
@@ -514,7 +513,7 @@ def extract_numeric_value(raw_value, is_umsatz=False):
|
||||
# logger.debug(f"extract_numeric_value: Verarbeite Wert: '{raw_value_str}' -> '{processed_value}' (is_umsatz={is_umsatz})") # Zu viel Lärm
|
||||
|
||||
# Entferne gängige Präfixe/Suffixe und Spannen-Trennzeichen
|
||||
processed_value = re.sub(r'(?i)^\s*(ca\.?|circa|rund|etwa|über|unter|mehr als|weniger als|bis zu)\s+', '', processed_value)
|
||||
processed_value = re.sub(r'(?i)^\s*(ca\.?|circa|rund|etwa|über|under|mehr als|weniger als|bis zu)\s+', '', processed_value)
|
||||
processed_value = re.sub(r'[€$£¥]', '', processed_value).strip()
|
||||
processed_value = re.split(r'\s*(-|–|bis)\s*', processed_value, 1)[0].strip() # Nimm nur den ersten Teil bei Spannen
|
||||
|
||||
@@ -823,15 +822,16 @@ def call_openai_chat(prompt, temperature=0.3, model=None):
|
||||
|
||||
if not prompt:
|
||||
logger.error("Fehler: Leerer Prompt für OpenAI.")
|
||||
# Werfen Sie eine Value Error Exception
|
||||
# Werfen Sie einen Value Error Exception
|
||||
raise ValueError("Leerer Prompt für OpenAI.")
|
||||
|
||||
current_model = model if model else getattr(Config, 'TOKEN_MODEL', 'gpt-3.5-turbo')
|
||||
|
||||
try:
|
||||
# Token zählen vor dem Senden (optional, gut für Debugging/Monitoring)
|
||||
# prompt_tokens = token_count(prompt, model=current_model)
|
||||
# logger.debug(f"Sende Prompt an OpenAI ({current_model}, geschätzt {prompt_tokens} Tokens)...") # Zu viel Lärm
|
||||
# try: prompt_tokens = token_count(prompt, model=current_model); logger.debug(f"Sende Prompt an OpenAI ({current_model}, geschätzt {prompt_tokens} Tokens)...");
|
||||
# except Exception as e_tc: logger.debug(f"Fehler beim Token-Zählen: {e_tc}"); # Logge Fehler beim Token-Zählen
|
||||
|
||||
|
||||
# Der OpenAI Call selbst kann Exceptions werfen (APIError, RateLimitError, InvalidRequestError etc.)
|
||||
# Diese werden vom @retry_on_failure Decorator behandelt.
|
||||
@@ -850,9 +850,8 @@ def call_openai_chat(prompt, temperature=0.3, model=None):
|
||||
result = response.choices[0].message.content.strip()
|
||||
|
||||
# Token zählen für die Antwort (optional)
|
||||
# completion_tokens = token_count(result, model=current_model)
|
||||
# total_tokens = response.usage.total_tokens # Usage info kann direkt aus response kommen
|
||||
# logger.debug(f"OpenAI Antwort erhalten ({completion_tokens} Completion Tokens, {total_tokens} Gesamt).") # Zu viel Lärm
|
||||
# try: completion_tokens = token_count(result, model=current_model); total_tokens = response.usage.total_tokens; logger.debug(f"OpenAI Antwort erhalten ({completion_tokens} Completion Tokens, {total_tokens} Gesamt).");
|
||||
# except Exception as e_tc: logger.debug(f"Fehler beim Token-Zählen der Antwort: {e_tc}"); # Logge Fehler beim Token-Zählen
|
||||
|
||||
return result # Gibt den bereinigten Antwortstring zurück
|
||||
|
||||
@@ -861,11 +860,7 @@ def call_openai_chat(prompt, temperature=0.3, model=None):
|
||||
except Exception as e:
|
||||
# Loggen Sie den unerwarteten Fehler
|
||||
logger.error(f"Allgemeiner Fehler während OpenAI-Aufruf: {type(e).__name__} - {e}")
|
||||
# Loggen Sie den Traceback für unerwartete Fehler
|
||||
# logger.exception("Traceback des allgemeinen Fehlers:") # Der Retry-Decorator loggt das
|
||||
|
||||
# Werfen Sie die Exception erneut, damit der retry_on_failure Decorator sie fangen kann.
|
||||
# Dies ist wichtig, damit der Decorator den Fehler erkennt und Retries durchführt.
|
||||
raise e
|
||||
|
||||
|
||||
@@ -876,7 +871,6 @@ def summarize_website_content(raw_text):
|
||||
return "k.A."
|
||||
|
||||
# Kürze den Rohtext, falls er sehr lang ist, um Token zu sparen/Limits zu vermeiden
|
||||
# Die maximale Länge des Prompts ist das Limit minus der erwarteten Antwortlänge.
|
||||
# Eine konservative Schätzung für den Text sind 3000 Zeichen.
|
||||
max_raw_length = 3000
|
||||
if len(str(raw_text)) > max_raw_length:
|
||||
@@ -886,7 +880,7 @@ def summarize_website_content(raw_text):
|
||||
prompt = (
|
||||
"Du bist ein KI-Assistent, der Webinhalte analysiert.\n"
|
||||
"Fasse den folgenden Text einer Unternehmenswebsite prägnant zusammen. "
|
||||
"Konzentriere dich auf:\n"
|
||||
"Konzentriere dich dabei auf:\n"
|
||||
"- Haupttätigkeitsfeld des Unternehmens\n"
|
||||
"- Wichtigste Produkte und/oder Dienstleistungen\n"
|
||||
"- Zielgruppe (falls erkennbar)\n\n"
|
||||
@@ -897,7 +891,6 @@ def summarize_website_content(raw_text):
|
||||
# Call_openai_chat nutzt den retry_on_failure Decorator.
|
||||
# Wenn call_openai_chat nach Retries eine Exception wirft, wird diese hier nicht gefangen,
|
||||
# sondern weitergereicht (z.B. an _process_single_row), was gut ist.
|
||||
# Wenn call_openai_chat erfolgreich ist, gibt es den String oder None zurück (obwohl es jetzt Exception bei Fehlern wirft).
|
||||
try:
|
||||
summary = call_openai_chat(prompt, temperature=0.2)
|
||||
return summary if summary and summary.strip() else "k.A. (Keine Zusammenfassung erhalten)"
|
||||
@@ -922,6 +915,7 @@ def summarize_batch_openai(tasks_data):
|
||||
dict: Ein Dictionary, das Zeilennummern auf ihre Zusammenfassungen mappt.
|
||||
z.B. {2122: "Zusammenfassung A", 2123: "Zusammenfassung B"}
|
||||
Bei Fehlern oder fehlenden Zusammenfassungen wird ein Fehlerstring verwendet.
|
||||
Wirft Exception bei API-Fehlern nach Retries.
|
||||
"""
|
||||
if not tasks_data: return {}
|
||||
|
||||
@@ -975,28 +969,30 @@ def summarize_batch_openai(tasks_data):
|
||||
final_prompt = "\n".join(prompt_parts)
|
||||
|
||||
# Optional: Token zählen zur Info, aber nicht zur Blockade
|
||||
# try: prompt_tokens = token_count(final_prompt); logger.debug(f"Geschätzte Prompt-Tokens für Batch: {prompt_tokens} (Limit ca. 4096 für gpt-3.5-turbo)");
|
||||
# try: prompt_tokens = token_count(final_prompt, model=getattr(Config, 'TOKEN_MODEL', 'gpt-3.5-turbo')); logger.debug(f"Geschätzt Prompt-Tokens für Batch: {prompt_tokens}.");
|
||||
# except Exception as e_tc: logger.debug(f"Fehler beim Token-Zählen: {e_tc}");
|
||||
|
||||
|
||||
# --- OpenAI API Call (Die API wirft Fehler bei Token-Limit oder anderen Problemen) ---
|
||||
# call_openai_chat nutzt den retry_on_failure Decorator und wirft bei endgültigem Fehler eine Exception
|
||||
chat_response = None
|
||||
# --- OpenAI API Call ---
|
||||
# call_openai_chat nutzt den retry_on_failure Decorator und wirft bei endgültigem Fehler eine Exception.
|
||||
# Der retry_on_failure Decorator DIESER summarize_batch_openai Funktion fängt die Exception
|
||||
# und führt die Retries für die GESAMTE Batch-Funktion durch.
|
||||
try:
|
||||
chat_response = call_openai_chat(final_prompt, temperature=0.2)
|
||||
# Wenn call_openai_chat erfolgreich ist, gibt es den String zurück.
|
||||
# Exceptions werden nach Retries geworfen.
|
||||
# Exceptions werden nach Retries geworfen und vom äußeren retry_on_failure dieser Funktion gefangen.
|
||||
|
||||
if not chat_response:
|
||||
# Dieser Fall sollte nach der Änderung in call_openai_chat nicht mehr auftreten (würde Exception werfen)
|
||||
logger.error("call_openai_chat gab unerwarteterweise None zurück für Batch-Zusammenfassung.")
|
||||
# Behandeln Sie dies als Fehler für alle Zeilen im Batch
|
||||
return {row_num: "FEHLER (OpenAI None Antwort)" for row_num in row_numbers_in_batch}
|
||||
# Werfen Sie eine spezifische Exception, damit der äußere Decorator sie fängt
|
||||
raise openai.error.APIError("Keine Antwort von OpenAI erhalten für Batch-Zusammenfassung.")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
# Wenn call_openai_chat nach Retries eine Exception wirft
|
||||
logger.error(f"Endgültiger FEHLER beim OpenAI-Batch-Aufruf für Zusammenfassung: {e}")
|
||||
# Wenn call_openai_chat oder der äußere retry_on_failure eine Exception wirft
|
||||
# Die Exception wird hier gefangen, bevor sie an den Aufrufer (DataProcessor Methode) weitergeleitet wird.
|
||||
logger.error(f"Endgültiger FEHLER beim OpenAI-Batch-Aufruf für Zusammenfassung (innerhalb Batch Decorator): {e}")
|
||||
# Geben Sie ein Dictionary zurück, das signalisiert, dass für alle Zeilen im Batch ein Fehler aufgetreten ist
|
||||
return {row_num: f"FEHLER API: {str(e)[:100]}" for row_num in row_numbers_in_batch}
|
||||
|
||||
@@ -1033,7 +1029,7 @@ def summarize_batch_openai(tasks_data):
|
||||
original_row_nums = {t['row_num'] for t in tasks_data}
|
||||
for row_num in original_row_nums:
|
||||
if row_num not in summaries:
|
||||
summaries[row_num] = "k.A. (Ungültiger Rohtext im Batch)"
|
||||
summaries[row_num] = "k.A. (Kein gültiger Rohtext im Batch)"
|
||||
|
||||
|
||||
return summaries # Rückgabe des Dictionarys mit Ergebnissen oder Fehlern
|
||||
@@ -1060,7 +1056,7 @@ def evaluate_branche_chatgpt(crm_branche, beschreibung, wiki_branche, wiki_kateg
|
||||
dict: Enthält "branch" (die finale, gültige Kurzform oder Fehler),
|
||||
"consistency" ('ok', 'X', 'fallback_crm_valid', 'fallback_invalid', 'error_...'),
|
||||
"justification" (Begründung von ChatGPT oder Fallback-Info).
|
||||
Wirft Exception bei API-Fehlern.
|
||||
Wirft Exception bei API-Fehlern (von call_openai_chat nach Retries).
|
||||
"""
|
||||
global ALLOWED_TARGET_BRANCHES, TARGET_SCHEMA_STRING
|
||||
|
||||
@@ -1182,362 +1178,7 @@ def evaluate_branche_chatgpt(crm_branche, beschreibung, wiki_branche, wiki_kateg
|
||||
result["justification"] = f"{fallback_reason} (ChatGPT Begründung war: {result.get('justification', 'Keine')})"
|
||||
logger.info(f"Fallback auf gültige CRM-Kurzform erfolgreich: '{final_branch}'")
|
||||
else:
|
||||
# 3. Wenn auch CRM-Kurzform ungültig
|
||||
final_branch = suggested_branch # Behalte ungültigen Vorschlag
|
||||
result["consistency"] = "fallback_invalid" # Setze Fehler-Fallback-Status
|
||||
error_reason = f"Fehler: Ungültiger ChatGPT-Vorschlag ('{suggested_branch}') und keine gültige CRM-Kurzform ('{crm_short_branch}') als Fallback verfügbar."
|
||||
result["justification"] = f"{error_reason} (ChatGPT Begründung war: {result.get('justification', 'Keine')})"
|
||||
logger.warning(f"Fallback fehlgeschlagen. Ungültiger Vorschlag: '{final_branch}', Ungültige CRM-Kurzform: '{crm_short_branch}'")
|
||||
# Alternativ: Setze final_branch auf einen expliziten Fehlerwert, um es im Sheet hervorzuheben
|
||||
# final_branch = "FEHLER - UNGÜLTIGE ZUWEISUNG"
|
||||
|
||||
|
||||
# Setze den finalen Branch im Ergebnis-Dictionary
|
||||
# Verwenden Sie einen Standard-Fehlerwert, falls final_branch aus irgendeinem Grund immer noch None ist
|
||||
result["branch"] = final_branch if final_branch else "FEHLER"
|
||||
|
||||
# --- Konsistenzprüfung (Finale Bewertung des final_branch vs. CRM-Kurzform) ---
|
||||
# Extrahiere CRM-Kurzform für den Vergleich (erneut oder Variable von oben)
|
||||
crm_short_to_compare = "k.A."
|
||||
if crm_branche and ">" in str(crm_branche):
|
||||
crm_short_to_compare = str(crm_branche).split(">", 1)[1].strip()
|
||||
elif crm_branche and str(crm_branche).strip() and str(crm_branche).strip() != "k.A.":
|
||||
crm_short_to_compare = str(crm_branche).strip()
|
||||
|
||||
|
||||
# Vergleiche finalen Branch (falls nicht FEHLER) mit CRM-Kurzform (case-insensitive)
|
||||
# Aktualisiere den Consistency-Status, WENN er noch 'pending_comparison' ist.
|
||||
# Fallback-Status ('fallback_crm_valid', 'fallback_invalid') sollen erhalten bleiben.
|
||||
if result["consistency"] == "pending_comparison" and result["branch"] != "FEHLER":
|
||||
if result["branch"].lower() == crm_short_to_compare.lower():
|
||||
result["consistency"] = "ok" # Übereinstimmung mit CRM
|
||||
else:
|
||||
result["consistency"] = "X" # Keine Übereinstimmung mit CRM
|
||||
|
||||
|
||||
# Entferne den temporären Status, falls er noch da ist (sollte nicht passieren)
|
||||
if result["consistency"] == "pending_comparison":
|
||||
logger.warning("Konsistenzprüfung blieb im Status 'pending_comparison', setze auf 'error_comparison_failed'.")
|
||||
result["consistency"] = "error_comparison_failed"
|
||||
elif result["consistency"] is None: # Sollte nicht passieren
|
||||
logger.error("Konsistenz blieb unerwartet None, setze auf 'error_unknown_state'.")
|
||||
result["consistency"] = "error_unknown_state"
|
||||
|
||||
|
||||
# Debug-Ausgabe des finalen Ergebnisses vor Rückgabe
|
||||
logger.debug(f"Finale Branch-Evaluation Ergebnis: Branch='{result.get('branch')}', Consistency='{result.get('consistency')}', Justification='{result.get('justification', '')[:100]}...'")
|
||||
|
||||
return result # Rückgabe des Ergebnis-Dictionarys
|
||||
|
||||
|
||||
# --- SERP API / LINKEDIN FUNCTIONS ---
|
||||
# Übernommen aus Ihrem Code (Teil 10), angepasst als globale Funktionen.
|
||||
|
||||
# serp_wikipedia_lookup ist bereits in Teil 1/18 enthalten (oder sollte es sein, da es direkt nach retry_on_failure kam)
|
||||
|
||||
|
||||
@retry_on_failure
|
||||
def serp_website_lookup(company_name):
|
||||
"""
|
||||
Ermittelt die offizielle Website eines Unternehmens über SerpAPI (Google Suche).
|
||||
Gibt die normalisierte URL zurück oder "k.A.".
|
||||
"""
|
||||
serp_key = Config.API_KEYS.get('serpapi')
|
||||
if not serp_key:
|
||||
logger.error("Fehler: SerpAPI Key nicht verfügbar für Website Lookup.")
|
||||
# Werfen Sie eine Exception, damit retry_on_failure dies behandeln kann
|
||||
raise ConnectionRefusedError("SerpAPI Key nicht konfiguriert.")
|
||||
|
||||
if not company_name or str(company_name).strip() == "":
|
||||
logger.warning("serp_website_lookup: Kein Firmenname angegeben.")
|
||||
# Werfen Sie einen ValueError
|
||||
raise ValueError("Kein Firmenname für SerpAPI Website Lookup angegeben.")
|
||||
|
||||
# Blacklist unerwünschter Domains (kann in Config verschoben werden)
|
||||
blacklist = ["bloomberg.com", "northdata.de", "finanzen.net", "handelsblatt.com", "wikipedia.org", "linkedin.com", "xing.com", "youtube.com", "facebook.com", "twitter.com", "instagram.com"]
|
||||
|
||||
query = f'{company_name} offizielle Website' # Präzisere Query
|
||||
params = {
|
||||
"engine": "google",
|
||||
"q": query,
|
||||
"api_key": serp_key,
|
||||
"hl": "de", # Host Language (Sprache der Benutzeroberfläche)
|
||||
"gl": "de", # Geo Location (Land)
|
||||
"safe": "active" # SafeSearch aktivieren
|
||||
}
|
||||
api_url = "https://serpapi.com/search"
|
||||
|
||||
try:
|
||||
# Der Requests Call wird vom retry_on_failure Decorator behandelt
|
||||
response = requests.get(api_url, params=params, timeout=getattr(Config, 'REQUEST_TIMEOUT', 15)) # Konfigurierbarer Timeout
|
||||
response.raise_for_status() # Wirft HTTPError für schlechte Antworten
|
||||
data = response.json()
|
||||
|
||||
# 1. Knowledge Graph prüfen (oft die offizielle Seite)
|
||||
if "knowledge_graph" in data and "website" in data["knowledge_graph"]:
|
||||
kg_url = data["knowledge_graph"].get("website")
|
||||
if kg_url:
|
||||
# Prüfen Blacklist VOR Normalisierung
|
||||
if any(bad_domain in kg_url.lower() for bad_domain in blacklist):
|
||||
logger.debug(f" -> SerpAPI Website Lookup: KG URL '{kg_url}' auf Blacklist. Übersprungen.")
|
||||
else:
|
||||
normalized_url = simple_normalize_url(kg_url) # Nutzt globale Funktion
|
||||
if normalized_url != "k.A.":
|
||||
logger.info(f"SERP Lookup: Website '{normalized_url}' aus Knowledge Graph für '{company_name}' gefunden.")
|
||||
return normalized_url # Erfolgreich gefunden und zurückgegeben
|
||||
|
||||
|
||||
# 2. Organische Ergebnisse prüfen
|
||||
if "organic_results" in data:
|
||||
# Iteriere durch die ersten Ergebnisse
|
||||
for result in data["organic_results"][:5]: # Prüfe nur die Top 5 organischen Ergebnisse
|
||||
url = result.get("link", "")
|
||||
title = result.get("title", "") # Titel kann Kontext geben
|
||||
snippet = result.get("snippet", "") # Snippet kann Kontext geben
|
||||
|
||||
# Filtere: Muss gültige URL sein, darf nicht auf Blacklist sein, muss http/https starten
|
||||
if url and url.lower().startswith(("http://", "https://")) and not any(bad_domain in url.lower() for bad_domain in blacklist):
|
||||
|
||||
normalized_url = simple_normalize_url(url) # Nutzt globale Funktion
|
||||
|
||||
if normalized_url != "k.A.":
|
||||
# Zusätzliche Plausibilitätsprüfung: Enthält die Domain Teile des Firmennamens?
|
||||
# Oder ist der Firmenname im Titel/Snippet?
|
||||
# normalize_company_name nutzt globale Funktion
|
||||
normalized_company = normalize_company_name(company_name)
|
||||
domain_part_normalized = normalized_url.replace('www.', '').split('.')[0] # Erster Teil der Domain
|
||||
title_lower = title.lower()
|
||||
snippet_lower = snippet.lower()
|
||||
|
||||
# Prüfe, ob der normalisierte Domain-Teil im normalisierten Firmennamen enthalten ist
|
||||
domain_name_match = domain_part_normalized in normalized_company
|
||||
# Prüfe, ob der normalisierte Firmenname im Titel oder Snippet vorkommt
|
||||
name_in_result_text = normalized_company in title_lower or normalized_company in snippet_lower
|
||||
|
||||
# Definieren Sie Kriterien für einen guten Treffer im organischen Ergebnis
|
||||
if domain_name_match or name_in_result_text:
|
||||
logger.info(f"SERP Lookup: Website '{normalized_url}' aus Organic Results für '{company_name}' gefunden (Domain/Name Match).")
|
||||
return normalized_url # Erfolgreich gefunden und zurückgegeben
|
||||
else:
|
||||
# Loggen Sie, warum die URL übersprungen wurde (nur auf Debug)
|
||||
logger.debug(f" -> SerpAPI Website Lookup: URL '{normalized_url}' übersprungen (Domain/Name Match fehlgeschlagen). Domain='{domain_part_normalized}', Name='{normalized_company}'.")
|
||||
# Fahren Sie fort, um den nächsten organischen Treffer zu prüfen
|
||||
|
||||
|
||||
# Wenn die Schleife durchläuft und keine passende URL gefunden wurde
|
||||
logger.info(f"SERP Lookup: Keine passende Website für '{company_name}' gefunden nach Prüfung KG und Top Organic Results.")
|
||||
return "k.A." # Signalisiert, dass keine passende URL gefunden wurde
|
||||
|
||||
except Exception as e: # retry_on_failure wirft Exception im Fehlerfall erneut
|
||||
# Loggen Sie den Fehler (wird vom retry_on_failure geloggt)
|
||||
logger.error(f"FEHLER bei der SerpAPI Website Suche für '{company_name}': {e}")
|
||||
# Geben Sie einen Fehlerwert zurück oder "k.A."
|
||||
return "k.A. (Fehler Suche)" # Signalisiert Fehler bei der Suche
|
||||
|
||||
|
||||
@retry_on_failure
|
||||
def search_linkedin_contacts(company_name, website, position_query, crm_kurzform, num_results=10):
|
||||
"""
|
||||
Sucht LinkedIn Kontakte für ein Unternehmen und eine Position via SerpAPI (Google).
|
||||
Gibt eine Liste von Kontakt-Dictionaries zurück.
|
||||
"""
|
||||
serp_key = Config.API_KEYS.get('serpapi')
|
||||
if not serp_key:
|
||||
logger.error("Fehler: SerpAPI Key nicht verfügbar für LinkedIn Suche.")
|
||||
raise ConnectionRefusedError("SerpAPI Key nicht konfiguriert.")
|
||||
|
||||
if not all([company_name, position_query, crm_kurzform]) or not all(isinstance(x, str) for x in [company_name, position_query, crm_kurzform]):
|
||||
logger.warning(f"search_linkedin_contacts: Fehlende oder ungültige Eingabedaten (Name, Position, Kurzform).")
|
||||
raise ValueError("Fehlende oder ungültige Eingabedaten für LinkedIn Suche.")
|
||||
|
||||
# Query anpassen für bessere Ergebnisse
|
||||
# Suche nach "[Position]" UND "[Firmenkurzform]" auf der LinkedIn /in/ Seite
|
||||
# crm_kurzform ist oft im Titel oder der Beschreibung
|
||||
query = f'site:linkedin.com/in/ "{position_query}" "{crm_kurzform}"'
|
||||
# Optional: Fügen Sie den vollen Firmennamen hinzu, kann aber die Ergebnisse einschränken
|
||||
# query = f'site:linkedin.com/in/ "{position_query}" "{crm_kurzform}" "{company_name}"'
|
||||
|
||||
params = {
|
||||
"engine": "google",
|
||||
"q": query,
|
||||
"api_key": serp_key,
|
||||
"hl": "de", # Host Language
|
||||
"gl": "de", # Geo Location
|
||||
"num": num_results # Anzahl der Ergebnisse pro SerpAPI Call
|
||||
}
|
||||
api_url = "https://serpapi.com/search"
|
||||
|
||||
found_contacts = [] # Liste zur Sammlung der gefundenen Kontakte
|
||||
|
||||
try:
|
||||
# Der Requests Call wird vom retry_on_failure Decorator behandelt
|
||||
response = requests.get(api_url, params=params, timeout=getattr(Config, 'REQUEST_TIMEOUT', 15)) # Konfigurierbarer Timeout
|
||||
response.raise_for_status() # Wirft HTTPError für schlechte Antworten
|
||||
data = response.json()
|
||||
|
||||
if "organic_results" in data:
|
||||
# Gehe durch die organischen Suchergebnisse
|
||||
for result in data["organic_results"]:
|
||||
title = result.get("title", "")
|
||||
linkedin_url = result.get("link", "")
|
||||
snippet = result.get("snippet", "") # Snippet kann Position oder Firma enthalten
|
||||
|
||||
# Filtere: Muss eine LinkedIn Profil-URL sein und die Kurzform muss im Titel vorkommen
|
||||
# oder eine hohe Namensähnlichkeit aufweisen
|
||||
if not linkedin_url or "linkedin.com/in/" not in linkedin_url or "/sales/" in linkedin_url:
|
||||
#logger.debug(f" -> LinkedIn Treffer übersprungen (kein Profil-URL): {linkedin_url}") # Zu viel Lärm
|
||||
continue
|
||||
|
||||
# Prüfe, ob die Firmenkurzform im Titel oder Snippet vorkommt
|
||||
# Oder ob der Titel eine hohe Ähnlichkeit mit "[Name] - [Position] bei [Kurzform]" hat
|
||||
title_lower = title.lower()
|
||||
snippet_lower = snippet.lower()
|
||||
crm_kurzform_lower = crm_kurzform.lower()
|
||||
position_query_lower = position_query.lower()
|
||||
|
||||
kurzform_in_text = crm_kurzform_lower in title_lower or crm_kurzform_lower in snippet_lower
|
||||
|
||||
# Vereinfachte Namens-/Positionsextraktion aus dem Titel
|
||||
name_part = ""
|
||||
pos_part = position_query # Fallback
|
||||
|
||||
# Versuche gängige Trennzeichen im Titel (z.B. Name - Position | Firma)
|
||||
separators = ["–", "-", "|", " at ", " bei "]
|
||||
title_cleaned = title.replace("...", "").strip()
|
||||
|
||||
found_sep = False
|
||||
for sep in separators:
|
||||
if sep in title_cleaned:
|
||||
parts = title_cleaned.split(sep, 1)
|
||||
name_part = parts[0].strip()
|
||||
# Versuche, LinkedIn/Profil etc. aus Namen zu entfernen
|
||||
name_part = re.sub(r'[\s|\-]*LinkedIn[\s|\-]*Profile.*$', '', name_part, flags=re.IGNORECASE).strip()
|
||||
name_part = re.sub(r'[\s|\-]*LinkedIn$', '', name_part, flags=re.IGNORECASE).strip()
|
||||
|
||||
|
||||
# Positionsteil ist alles nach dem ersten Trenner
|
||||
potential_pos_company = parts[1].strip()
|
||||
# Versuche, Firmennamen-Teile (Kurzform) und LinkedIn-Suffixe zu entfernen
|
||||
pos_company_cleaned = re.sub(r'[\s|\-]*LinkedIn[\s|\-]*Profile.*$', '', potential_pos_company, flags=re.IGNORECASE).strip()
|
||||
pos_company_cleaned = re.sub(r'[\s|\-]*LinkedIn$', '', pos_company_cleaned, flags=re.IGNORECASE).strip()
|
||||
|
||||
# Entferne die Firmenkurzform, wenn sie im Positionsteil vorkommt
|
||||
if crm_kurzform_lower in pos_company_cleaned.lower():
|
||||
# Ersetze nur die erste gefundene Instanz der Kurzform (ganzes Wort)
|
||||
pos_company_cleaned = re.sub(r'\b' + re.escape(crm_kurzform_lower) + r'\b', '', pos_company_cleaned, flags=re.IGNORECASE).strip()
|
||||
pos_company_cleaned = re.sub(r'\s+', ' ', pos_company_cleaned).strip() # Leerzeichen reduzieren nach Entfernung
|
||||
|
||||
pos_part = pos_company_cleaned if pos_company_cleaned else position_query
|
||||
found_sep = True
|
||||
break
|
||||
|
||||
if not found_sep: # Kein Trennzeichen gefunden, versuche andere Muster
|
||||
# Muster: "[Name] [Position_Query] - LinkedIn"
|
||||
if position_query_lower in title_lower:
|
||||
# Split am Position_Query, nimm den Teil davor als Namen
|
||||
name_before_pos = title_lower.split(position_query_lower, 1)[0].strip()
|
||||
name_part = title_cleaned[:len(name_before_pos)].strip() # Nimm Originaltext bis zur Position
|
||||
|
||||
# Teile Namen in Vor- und Nachname (einfache Annahme)
|
||||
firstname = ""
|
||||
lastname = ""
|
||||
name_parts = name_part.split()
|
||||
if len(name_parts) > 1:
|
||||
firstname = name_parts[0]
|
||||
lastname = " ".join(name_parts[1:])
|
||||
elif len(name_parts) == 1:
|
||||
firstname = name_parts[0] # Nur Vorname gefunden?
|
||||
|
||||
if not firstname or not name_part: # Wenn Name nicht extrahiert werden konnte, überspringe
|
||||
# self.logger.debug(f"LinkedIn Treffer übersprungen: Name konnte nicht extrahiert werden aus Titel '{title}'") # Zu viel Lärm
|
||||
continue
|
||||
|
||||
# Zusätzliche Plausibilitätsprüfung: Position Query muss im Titel oder Snippet vorkommen ODER Kurzform muss im Titel/Snippet sein
|
||||
position_in_text = position_query_lower in title_lower or position_query_lower in snippet_lower
|
||||
|
||||
# Akzeptiere den Kontakt, wenn (Position oder Kurzform in Text) UND Name extrahiert wurde
|
||||
if position_in_text or kurzform_in_text:
|
||||
contact_data = {
|
||||
"Firmenname": company_name, # Originalname für Kontext
|
||||
"CRM Kurzform": crm_kurzform,
|
||||
"Website": website, # Website der Firma
|
||||
"Vorname": firstname,
|
||||
"Nachname": lastname,
|
||||
"Position": pos_part, # Extrahierte oder Fallback Position
|
||||
"LinkedInURL": linkedin_url
|
||||
}
|
||||
found_contacts.append(contact_data)
|
||||
# self.logger.debug(f"Gefundener LinkedIn Kontakt: {firstname} {lastname} - {pos_part} (URL: {linkedin_url})") # Zu viel Lärm
|
||||
# else: self.logger.debug(f"LinkedIn Treffer übersprungen (kein Position/Kurzform Match in Text): '{title}'") # Zu viel Lärm
|
||||
|
||||
|
||||
logger.info(f"LinkedIn Suche für '{position_query}' bei '{crm_kurzform}' ergab {len(found_contacts)} Kontakte.")
|
||||
return found_contacts # Gibt die Liste der gefundenen Kontakte zurück
|
||||
|
||||
except Exception as e: # retry_on_failure wirft Exception im Fehlerfall erneut
|
||||
# Loggen Sie den Fehler (wird vom retry_on_failure geloggt)
|
||||
logger.error(f"FEHLER bei der SerpAPI LinkedIn Suche (Query: '{position_query}', Firma: '{crm_kurzform}'): {e}")
|
||||
# Geben Sie eine leere Liste zurück, da keine Kontakte gefunden wurden
|
||||
return [] # Signalisiert Fehler bei der Suche
|
||||
|
||||
|
||||
# --- Experimentelle Website Details Scraping Funktion ---
|
||||
# Diese Funktion wurde in DataProcessor.process_website_details aufgerufen
|
||||
# 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.
|
||||
Diese Funktion muss je nach Zielwebsite(s) implementiert/angepasst werden.
|
||||
|
||||
Args:
|
||||
url (str): Die URL der Website.
|
||||
|
||||
Returns:
|
||||
str: Extrahierte Details als String oder Fehler/k.A.
|
||||
"""
|
||||
logger.warning(f"Platzhalter Funktion 'scrape_website_details' für URL {url} aufgerufen.")
|
||||
# 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
|
||||
@retry_on_failure
|
||||
def get_soup_for_details(target_url):
|
||||
response = requests.get(target_url, timeout=getattr(Config, 'REQUEST_TIMEOUT', 15))
|
||||
response.raise_for_status()
|
||||
response.encoding = response.apparent_encoding
|
||||
return BeautifulSoup(response.text, getattr(Config, 'HTML_PARSER', 'html.parser'))
|
||||
|
||||
soup = get_soup_for_details(url)
|
||||
|
||||
if soup:
|
||||
title = soup.find('title')
|
||||
meta_desc = soup.find('meta', attrs={'name': 'description'})
|
||||
h1 = soup.find('h1')
|
||||
|
||||
details_list = []
|
||||
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())}")
|
||||
|
||||
if details_list:
|
||||
return " | ".join(details_list)
|
||||
else:
|
||||
return "k.A. (Keine Standard-Details gefunden)"
|
||||
|
||||
else:
|
||||
return "k.A. (Scraping fehlgeschlagen)" # Fehler wurde bereits geloggt
|
||||
|
||||
except Exception as e: # retry_on_failure wirft am Ende Exception
|
||||
logger.error(f"FEHLER in scrape_website_details für {url}: {e}")
|
||||
return f"FEHLER: {str(e)[:100]}" # Rückgabe der Fehlermeldung
|
||||
|
||||
|
||||
# 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.
|
||||
# 3. Wenn auch CRM-Kurzform ungült
|
||||
|
||||
# ==============================================================================
|
||||
# 4. GOOGLE SHEET HANDLER CLASS
|
||||
|
||||
Reference in New Issue
Block a user