From bcf8df59270e7b4ff541eddbb0304a7eae58a899 Mon Sep 17 00:00:00 2001 From: Floke Date: Mon, 5 May 2025 13:12:55 +0000 Subject: [PATCH] Reset --- brancheneinstufung.py | 439 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 399 insertions(+), 40 deletions(-) diff --git a/brancheneinstufung.py b/brancheneinstufung.py index 37f87d8d..389dc73b 100644 --- a/brancheneinstufung.py +++ b/brancheneinstufung.py @@ -252,7 +252,7 @@ decorator_logger = logging.getLogger(__name__ + ".Retry") # --- Retry Decorator --- -# KORRIGIERTE Version (Behandelt SpreadsheetNotFound explizit) +# KORRIGIERTE Version 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 (nicht die spezifischen API/Netzwerkfehler) + # Log traceback für unerwartete Fehler 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,15 +293,7 @@ def retry_on_failure(func): return func(*args, **kwargs) # Call the original function # Spezifische Exceptions, die ein Retry rechtfertigen - # 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 ().") - # 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: + except (requests.exceptions.RequestException, gspread.exceptions.APIError, openai.error.OpenAIError, wikipedia.exceptions.WikipediaException) as e: error_msg = str(e) error_type = type(e).__name__ @@ -325,6 +317,7 @@ 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 @@ -337,12 +330,20 @@ 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. - # 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 + # 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. # Die wrapper Funktion muss am Ende zurückgegeben werden return wrapper @@ -513,7 +514,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|under|mehr als|weniger als|bis zu)\s+', '', processed_value) + 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'[€$£¥]', '', processed_value).strip() processed_value = re.split(r'\s*(-|–|bis)\s*', processed_value, 1)[0].strip() # Nimm nur den ersten Teil bei Spannen @@ -822,16 +823,15 @@ def call_openai_chat(prompt, temperature=0.3, model=None): if not prompt: logger.error("Fehler: Leerer Prompt für OpenAI.") - # Werfen Sie einen Value Error Exception + # Werfen Sie eine 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) - # 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 - + # 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 # Der OpenAI Call selbst kann Exceptions werfen (APIError, RateLimitError, InvalidRequestError etc.) # Diese werden vom @retry_on_failure Decorator behandelt. @@ -850,8 +850,9 @@ 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) - # 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 + # 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 return result # Gibt den bereinigten Antwortstring zurück @@ -860,7 +861,11 @@ 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 @@ -871,6 +876,7 @@ 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: @@ -880,7 +886,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 dabei auf:\n" + "Konzentriere dich auf:\n" "- Haupttätigkeitsfeld des Unternehmens\n" "- Wichtigste Produkte und/oder Dienstleistungen\n" "- Zielgruppe (falls erkennbar)\n\n" @@ -891,6 +897,7 @@ 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)" @@ -915,7 +922,6 @@ 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 {} @@ -969,30 +975,28 @@ 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, model=getattr(Config, 'TOKEN_MODEL', 'gpt-3.5-turbo')); logger.debug(f"Geschätzt Prompt-Tokens für Batch: {prompt_tokens}."); + # 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)"); # except Exception as e_tc: logger.debug(f"Fehler beim Token-Zählen: {e_tc}"); - # --- 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. + # --- 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 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 und vom äußeren retry_on_failure dieser Funktion gefangen. + # Exceptions werden nach Retries geworfen. 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.") - # 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.") + # 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} except Exception as 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}") + # Wenn call_openai_chat nach Retries eine Exception wirft + logger.error(f"Endgültiger FEHLER beim OpenAI-Batch-Aufruf für Zusammenfassung: {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} @@ -1029,7 +1033,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. (Kein gültiger Rohtext im Batch)" + summaries[row_num] = "k.A. (Ungültiger Rohtext im Batch)" return summaries # Rückgabe des Dictionarys mit Ergebnissen oder Fehlern @@ -1056,7 +1060,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 (von call_openai_chat nach Retries). + Wirft Exception bei API-Fehlern. """ global ALLOWED_TARGET_BRANCHES, TARGET_SCHEMA_STRING @@ -1178,7 +1182,362 @@ 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ült + # 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 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. # ============================================================================== # 4. GOOGLE SHEET HANDLER CLASS