This commit is contained in:
2025-04-25 13:05:14 +00:00
parent 26508f945d
commit e1bfa5d853

View File

@@ -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