From e1bfa5d8534939dd4025ee0ccc07a0ae9fef75ae Mon Sep 17 00:00:00 2001 From: Floke Date: Fri, 25 Apr 2025 13:05:14 +0000 Subject: [PATCH] bugfix --- brancheneinstufung.py | 439 ++++-------------------------------------- 1 file changed, 40 insertions(+), 399 deletions(-) diff --git a/brancheneinstufung.py b/brancheneinstufung.py index 389dc73b..37f87d8d 100644 --- a/brancheneinstufung.py +++ b/brancheneinstufung.py @@ -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 ().") + # 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 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