Files
Brancheneinstufung2/brancheneinstufung.py
2025-04-22 11:18:10 +00:00

4846 lines
265 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
v1.6.6: Füge SerpAPI-Suche für fehlende Wiki-URLs großer Firmen hinzu
Git-Änderungsbeschreibung:
- Füge neuen Betriebsmodus `--mode find_wiki_serp` hinzu.
- Implementiere neue Funktion `serp_wikipedia_lookup`, die SerpAPI nutzt, um gezielt nach Wikipedia-Artikeln für einen Firmennamen zu suchen.
- Implementiere neue Funktion `process_find_wiki_with_serp`:
- Lädt aktuelle Sheet-Daten.
- Filtert Zeilen, bei denen Spalte M (Wiki URL) leer/'k.A.' ist UND Spalte K (CRM Mitarbeiter) einen Schwellenwert (Standard: 500) überschreitet.
- Ruft `serp_wikipedia_lookup` für gefilterte Zeilen auf.
- Bei erfolgreicher URL-Findung:
- Schreibt die gefundene URL in Spalte M.
- Setzt Flag 'x' in Spalte A (ReEval Flag).
- Löscht Timestamps in Spalten AN (Wikipedia Timestamp) und AO (Timestamp letzte Prüfung).
- Führt gebündelte Sheet-Updates am Ende durch.
- Integriere den neuen Modus `find_wiki_serp` in die Argumentenverarbeitung und Ausführungslogik der `main`-Funktion.
- Füge notwendige Imports hinzu und stelle sicher, dass die neuen Funktionen Logging verwenden.
- Aktualisiere Versionsnummer in `Config.VERSION` auf v1.6.6.
"""
import os
import time
import re
import gspread
import wikipedia
import requests
import openai
from bs4 import BeautifulSoup
from oauth2client.service_account import ServiceAccountCredentials
from datetime import datetime
from difflib import SequenceMatcher
import unicodedata
import csv
import gender_guesser.detector as gender
# --- HIER unquote hinzufügen ---
from urllib.parse import urlparse, urlencode, unquote
import argparse
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split, GridSearchCV
from sklearn.impute import SimpleImputer
from sklearn.tree import DecisionTreeClassifier, export_text
from sklearn.metrics import accuracy_score, classification_report, confusion_matrix
import json
import pickle
import concurrent.futures
import threading
import traceback # Importiere traceback für detailliertere Fehlermeldungen
import logging
from urllib.parse import unquote # Wird für API-Check benötigt
# --- Ende neue Importe ---
# Optional: tiktoken für Token-Zählung (Modus 8)
try:
import tiktoken
except ImportError:
tiktoken = None
# ==================== KONSTANTEN ====================
CREDENTIALS_FILE = "service_account.json"
API_KEY_FILE = "api_key.txt"
SERP_API_KEY_FILE = "serpApiKey.txt"
GENDERIZE_API_KEY_FILE = "genderize_API_Key.txt"
BRANCH_MAPPING_FILE = "ziel_Branchenschema.csv"
LOG_DIR = "Log"
# --- NEU: Dateinamen für Modell-Artefakte ---
MODEL_FILE = "technician_decision_tree_model.pkl"
IMPUTER_FILE = "median_imputer.pkl"
PATTERNS_FILE_TXT = "technician_patterns.txt"
PATTERNS_FILE_JSON = "technician_patterns.json" # Optional
# ==================== KONFIGURATION ====================
class Config:
# ... (Alle deine bisherigen Config-Einstellungen) ...
VERSION = "v1.6.6" # Versionsnummer erhöhen
LANG = "de"
SHEET_URL = "https://docs.google.com/spreadsheets/d/1u_gHr9JUfmV1-iviRzbSe3575QEp7KLhK5jFV_gJcgo"
MAX_RETRIES = 3
RETRY_DELAY = 5
SIMILARITY_THRESHOLD = 0.65
DEBUG = True
WIKIPEDIA_SEARCH_RESULTS = 5
HTML_PARSER = "html.parser"
TOKEN_MODEL = "gpt-3.5-turbo"
# --- Konfiguration für Batching & Parallelisierung ---
BATCH_SIZE = 10 # Batch-Größe für Wiki Verification (_process_batch)
PROCESSING_BATCH_SIZE = 20 # Wie viele Zeilen pro Verarbeitungs-Batch sammeln (Website Scraping)
OPENAI_BATCH_SIZE_LIMIT = 4 # Max. Texte pro OpenAI Call in summarize_batch_openai
MAX_SCRAPING_WORKERS = 10 # Threads für paralleles Website-Scraping
UPDATE_BATCH_ROW_LIMIT = 50 # Zeilen sammeln für gebündelte Sheet Updates
MAX_BRANCH_WORKERS = 10 # Threads für parallele Branch-Bewertung?
OPENAI_CONCURRENCY_LIMIT = 5 # Max. gleichzeitige OpenAI Calls für Branch Bewertung
PROCESSING_BRANCH_BATCH_SIZE = PROCESSING_BATCH_SIZE # Nutze dieselbe Batch-Größe wie Website, oder definiere neu (z.B. 20)
API_KEYS = {}
@classmethod
def load_api_keys(cls):
"""Lädt API-Schlüssel aus den definierten Dateien."""
logging.info("Lade API-Schlüssel...")
cls.API_KEYS['openai'] = cls._load_key_from_file(API_KEY_FILE)
cls.API_KEYS['serpapi'] = cls._load_key_from_file(SERP_API_KEY_FILE)
cls.API_KEYS['genderize'] = cls._load_key_from_file(GENDERIZE_API_KEY_FILE)
if cls.API_KEYS.get('openai'):
openai.api_key = cls.API_KEYS['openai']
logging.info("OpenAI API Key erfolgreich geladen.")
else:
# Dies ist eine Warnung, keine blockierende Fehlermeldung
logging.warning("OpenAI API Key konnte nicht geladen werden (Datei fehlt oder ist leer?).")
@staticmethod
def _load_key_from_file(filepath):
"""Hilfsfunktion zum Laden eines Schlüssels aus einer Datei."""
try:
with open(filepath, "r", encoding="utf-8") as f: # Encoding hinzugefügt
key = f.read().strip()
if key:
logging.debug(f"Schlüssel aus '{filepath}' erfolgreich geladen.")
return key
else:
logging.warning(f"Datei '{filepath}' ist leer.")
return None
except FileNotFoundError:
# Info, da das Fehlen eines Keys nicht immer ein Fehler sein muss
logging.info(f"API-Schlüsseldatei '{filepath}' nicht gefunden.")
return None
except Exception as e:
# Error, wenn beim Lesen ein anderer Fehler auftritt
logging.error(f"Fehler beim Lesen der Schlüsseldatei '{filepath}': {e}")
return None
# Globales Mapping-Dictionary und Schema-String
BRANCH_MAPPING = {}
TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfügbar."
ALLOWED_TARGET_BRANCHES = []
# Logging-Setup (nehme an, es ist global konfiguriert)
logger = logging.getLogger(__name__)
# Globales Spalten-Mapping (Beispiel basierend auf Zeile 4 - Kurze Beschreibung)
# TODO: Dieses Mapping vervollständigen und durchgängig verwenden!
COLUMN_MAP = {
"ReEval Flag": 0, # A
"CRM Name": 1, # B
"CRM Kurzform": 2, # C
"CRM Website": 3, # D
"CRM Ort": 4, # E
"CRM Beschreibung": 5, # F
"CRM Branche": 6, # G
"CRM Beschreibung Branche extern": 7, # H
"CRM Anzahl Techniker": 8, # I
"CRM Umsatz": 9, # J
"CRM Anzahl Mitarbeiter": 10, # K
"CRM Vorschlag Wiki URL": 11, # L
"Wiki URL": 12, # M
"Wiki Absatz": 13, # N
"Wiki Branche": 14, # O
"Wiki Umsatz": 15, # P
"Wiki Mitarbeiter": 16, # Q
"Wiki Kategorien": 17, # R
"Chat Wiki Konsistenzprüfung": 18, # S
"Chat Begründung Wiki Inkonsistenz": 19, # T
"Chat Vorschlag Wiki Artikel": 20, # U
"Begründung bei Abweichung": 21, # V
"Chat Vorschlag Branche": 22, # W
"Chat Konsistenz Branche": 23, # X
"Chat Begründung Abweichung Branche": 24, # Y
"Chat Prüfung FSM Relevanz": 25, # Z
"Chat Begründung für FSM Relevanz": 26, # AA
"Chat Schätzung Anzahl Mitarbeiter": 27, # AB
"Chat Konsistenzprüfung Mitarbeiterzahl": 28, # AC
"Chat Begründung Abweichung Mitarbeiterzahl": 29, # AD
"Chat Einschätzung Anzahl Servicetechniker": 30, # AE
"Chat Begründung Abweichung Anzahl Servicetechniker": 31, # AF
"Chat Schätzung Umsatz": 32, # AG
"Chat Begründung Abweichung Umsatz": 33, # AH
"Linked Serviceleiter gefunden": 34, # AI
"Linked It-Leiter gefunden": 35, # AJ
"Linked Management gefunden": 36, # AK
"Linked Disponent gefunden": 37, # AL
"Contact Search Timestamp": 38, # AM
"Wikipedia Timestamp": 39, # AN (Zeitpunkt der Datenextraktion)
"Timestamp letzte Prüfung": 40, # AO (Zeitpunkt der Branch-Einschätzung)
"Version": 41, # AP
"Tokens": 42, # AQ
"Website Rohtext": 43, # AR
"Website Zusammenfassung": 44, # AS
"Website Scrape Timestamp": 45, # AT
"Geschätzter Techniker Bucket": 46, # AU
"Finaler Umsatz (Wiki>CRM)": 47,# AV
"Finaler Mitarbeiter (Wiki>CRM)": 48, # AW
"Wiki Verif. Timestamp": 49, # AX (NEU)
"SerpAPI Wiki Search Timestamp": 50 # AY (NEU)
}
# Hinweis: Index ist 0-basiert, Spaltenbuchstaben sind 1-basiert (A=1, AW=49)
# Annahme: COLUMN_MAP ist global definiert und enthält mindestens:
# "CRM Name", "CRM Branche", "CRM Umsatz", "Wiki Umsatz",
# "CRM Anzahl Mitarbeiter", "Wiki Mitarbeiter", "CRM Anzahl Techniker" (oder wo immer die bekannte Technikerzahl steht)
# Stelle sicher, dass die globale COLUMN_MAP verfügbar ist
# Beispielhafte Definition (bitte an deine Spalten anpassen!)
# COLUMN_MAP = { ... dein komplettes Mapping ... }
# ==================== RETRY-DECORATOR ====================
def retry_on_failure(func):
def wrapper(*args, **kwargs):
func_name = func.__name__
# Versuche, das 'self' Argument für Methoden zu extrahieren
self_arg = args[0] if args and hasattr(args[0], func_name) else None
effective_func_name = f"{self_arg.__class__.__name__}.{func_name}" if self_arg else func_name
for attempt in range(Config.MAX_RETRIES):
try:
return func(*args, **kwargs)
except Exception as e:
error_msg = str(e)
# Spezifische Fehlerbehandlung (Beispiel)
if isinstance(e, gspread.exceptions.APIError):
if e.response.status_code == 429: # Rate Limit
wait_time = Config.RETRY_DELAY * (attempt + 1) # Exponential backoff
print(f"🚦 Rate Limit bei {effective_func_name} (Versuch {attempt+1}). Warte {wait_time}s... Fehler: {error_msg[:100]}")
time.sleep(wait_time)
continue # Direkt zum nächsten Versuch
else:
print(f"⚠️ Google API Fehler bei {effective_func_name} (Versuch {attempt+1}): {error_msg[:100]}")
elif isinstance(e, requests.exceptions.RequestException):
print(f"⚠️ Netzwerkfehler bei {effective_func_name} (Versuch {attempt+1}): {error_msg[:100]}")
elif isinstance(e, openai.error.OpenAIError):
print(f"⚠️ OpenAI Fehler bei {effective_func_name} (Versuch {attempt+1}): {error_msg[:100]}")
else:
print(f"⚠️ Unbekannter Fehler bei {effective_func_name} (Versuch {attempt+1}): {type(e).__name__} - {error_msg[:100]}")
if attempt < Config.MAX_RETRIES - 1:
time.sleep(Config.RETRY_DELAY)
else:
print(f"❌ Endgültiger Fehler bei {effective_func_name} nach {Config.MAX_RETRIES} Versuchen.")
return None # Oder eine spezifische Fehlerkennung zurückgeben
return None # Sollte nicht erreicht werden, aber zur Sicherheit
return wrapper
@retry_on_failure # Annahme: Decorator existiert
def serp_wikipedia_lookup(company_name, website=None, min_score=0.4):
"""
Sucht über SerpAPI (Google) nach dem wahrscheinlichsten Wikipedia-Artikel.
Verwendet flexible Query, sammelt Top-10-Kandidaten, bewertet nach Titelähnlichkeit
und Keywords, bevorzugt deutsche/englische Artikel.
Args:
company_name (str): Der Name des Unternehmens.
website (str, optional): Die Website des Unternehmens. Defaults to None.
min_score (float, optional): Mindest-Score (Kombination aus Ähnlichkeit
und Boni) für einen gültigen Treffer. Defaults to 0.4.
Returns:
str: Die URL des relevantesten Wikipedia-Artikels oder None.
"""
serp_key = Config.API_KEYS.get('serpapi')
if not serp_key:
logging.error("SerpAPI Key nicht verfügbar für Wikipedia Lookup.")
return None
if not company_name:
logging.warning("serp_wikipedia_lookup: Kein Firmenname angegeben.")
return None
# --- Flexiblere Query Konstruktion ---
# Ohne Anführungszeichen für breitere Suche
query = f'{company_name} Wikipedia'
logging.info(f"Starte SerpAPI Wikipedia-Suche für '{company_name}' mit Query: '{query}'")
# --- Ende Query ---
params = {
"engine": "google", "q": query, "api_key": serp_key,
"hl": "de", "gl": "de",
"num": 10 # Top 10 Ergebnisse
}
api_url = "https://serpapi.com/search"
try:
response = requests.get(api_url, params=params, timeout=15)
response.raise_for_status()
data = response.json()
candidates = [] # Liste von Dictionaries: {'url': str, 'title': str}
if "organic_results" in data:
logging.debug(f" -> Prüfe {len(data['organic_results'])} organische Ergebnisse...")
for result in data["organic_results"]: # Prüfe alle 10
link = result.get("link")
# Filtere gültige Wiki-Artikel-Links (de oder en)
if link and "wikipedia.org/wiki/" in link.lower() \
and (link.startswith("https://de.wikipedia.org") or link.startswith("https://en.wikipedia.org")) \
and not any(x in link for x in ['Datei:', 'Spezial:', 'Portal:', 'Hilfe:', 'Diskussion:']):
try:
# Extrahiere Titel aus URL
title_part = link.split('/wiki/', 1)[1]
# Handle evtl. Anchors (#)
title_part = title_part.split('#')[0]
title = unquote(title_part).replace('_', ' ')
candidates.append({'url': link, 'title': title})
logging.debug(f" -> Kandidat gefunden: '{title}' ({link})")
except Exception as e_title_extract:
logging.warning(f" -> Fehler beim Extrahieren des Titels aus Link {link}: {e_title_extract}")
continue # Nächsten Kandidaten prüfen
if not candidates:
logging.warning(f" -> SerpAPI: Keine de/en Wikipedia-Kandidaten-URLs in Ergebnissen für '{company_name}' gefunden.")
return None
# Bewerte Kandidaten
best_match_url = None
highest_score = -1.0
normalized_search_name = normalize_company_name(company_name) # Annahme: existiert
logging.debug(f" -> Bewerte {len(candidates)} Kandidaten...")
for cand in candidates:
url = cand['url']
title = cand['title']
try: # Füge Try-Except um die Normalisierung hinzu
normalized_title = normalize_company_name(title)
title_lower = title.lower() # Für Keyword-Suche
except Exception as e_norm:
logging.warning(f"Fehler beim Normalisieren des Titels '{title}': {e_norm}")
continue # Überspringe diesen Kandidaten
# 1. Basisscore: Titelähnlichkeit
similarity = SequenceMatcher(None, normalized_title, normalized_search_name).ratio()
score = similarity
logging.debug(f" -> Kandidat '{title}': Basis-Ähnlichkeit={similarity:.2f}")
# 2. Bonus für Keywords im Titel
bonus = 0.0
if "(unternehmen)" in title_lower:
bonus += 0.2 # Starker Bonus
logging.debug(" -> Bonus +0.2 für '(unternehmen)'")
elif any(form in title_lower for form in [' gmbh', ' ag', ' kg', ' ltd', ' inc', ' corp', ' s.a.', ' se', ' group']): # 'group' hinzugefügt
bonus += 0.1 # Kleinerer Bonus
logging.debug(" -> Bonus +0.1 für Rechtsform/Gruppen-Keyword")
# 3. Bonus für Sprache (Deutsch bevorzugt)
if url.startswith("https://de.wikipedia.org"):
bonus += 0.05
logging.debug(" -> Bonus +0.05 für de.wikipedia.org")
# Gesamtscore
total_score = score + bonus
logging.debug(f" -> Gesamtscore für '{title}': {total_score:.3f} (Ähnlichkeit={similarity:.2f}, Bonus={bonus:.2f})")
# Aktualisiere besten Treffer
if total_score > highest_score and total_score >= min_score:
highest_score = total_score
best_match_url = url
logging.debug(f" ====> Neuer bester Kandidat: {best_match_url} (Score: {highest_score:.3f}) ====")
if best_match_url:
logging.info(f" -> SerpAPI: Bester relevanter Wikipedia-Link ausgewählt: {best_match_url} (Score: {highest_score:.3f})")
return best_match_url
else:
logging.warning(f" -> SerpAPI: Keiner der {len(candidates)} Kandidaten erreichte den Mindestscore ({min_score}) für '{company_name}'.")
return None
except requests.exceptions.RequestException as e:
logging.error(f"Fehler bei der SerpAPI Wikipedia Suche für '{company_name}': {e}")
raise e # Fehler weitergeben für Retry
except Exception as e:
logging.error(f"Allgemeiner Fehler bei der SerpAPI Wikipedia Suche für '{company_name}': {e}")
return None # Bei unerwarteten Fehlern None zurückgeben
# Kann als eigenständige Funktion oder Methode in DataProcessor implementiert werden
def process_find_wiki_with_serp(sheet_handler, row_limit=None, min_employees=500):
"""
Sucht fehlende Wikipedia-URLs (Spalte M = k.A.) für Unternehmen mit > min_employees
über SerpAPI und trägt gefundene URLs in Spalte M ein. Setzt ReEval-Flag (A)
und löscht abhängige Wiki-Spalten (N-V, AN, AO, AP, AX).
Merkt sich in Spalte AY, wann die Suche durchgeführt wurde.
Args:
sheet_handler (GoogleSheetHandler): Initialisierte Instanz.
row_limit (int, optional): Maximale Anzahl zu prüfender Zeilen. Defaults to None.
min_employees (int, optional): Mindestanzahl Mitarbeiter (Spalte K) als Filter. Defaults to 500.
"""
logging.info(f"Starte Modus 'find_wiki_serp': Suche fehlende Wiki-URLs für Firmen > {min_employees} MA...")
if not sheet_handler.load_data(): return
all_data = sheet_handler.get_all_data_with_headers()
if not all_data or len(all_data) <= 5:
logging.warning("Keine oder zu wenige Daten im Sheet für 'find_wiki_serp' gefunden.")
return
header_rows = 5
data_rows = all_data[header_rows:]
# Benötigte Spaltenindizes holen (inkl. aller zu löschenden Spalten)
try:
col_indices = {
"A": COLUMN_MAP["ReEval Flag"],
"K": COLUMN_MAP["CRM Anzahl Mitarbeiter"],
"M": COLUMN_MAP["Wiki URL"],
"B": COLUMN_MAP["CRM Name"],
"N": COLUMN_MAP["Wiki Absatz"], # NEU zum Löschen
"O": COLUMN_MAP["Wiki Branche"], # NEU zum Löschen
"P": COLUMN_MAP["Wiki Umsatz"], # NEU zum Löschen
"Q": COLUMN_MAP["Wiki Mitarbeiter"], # NEU zum Löschen
"R": COLUMN_MAP["Wiki Kategorien"], # NEU zum Löschen
"S": COLUMN_MAP["Chat Wiki Konsistenzprüfung"], # NEU zum Löschen
"T": COLUMN_MAP["Chat Begründung Wiki Inkonsistenz"], # NEU zum Löschen
"U": COLUMN_MAP["Chat Vorschlag Wiki Artikel"], # NEU zum Löschen
"V": COLUMN_MAP["Begründung bei Abweichung"], # NEU zum Löschen
"AN": COLUMN_MAP["Wikipedia Timestamp"],
"AO": COLUMN_MAP["Timestamp letzte Prüfung"],
"AP": COLUMN_MAP["Version"], # NEU zum Löschen
"AX": COLUMN_MAP["Wiki Verif. Timestamp"], # NEU zum Löschen
"AY": COLUMN_MAP["SerpAPI Wiki Search Timestamp"]
}
col_letters = {key: sheet_handler._get_col_letter(idx + 1) for key, idx in col_indices.items()}
except KeyError as e:
logging.critical(f"FEHLER: Benötigter Spaltenschlüssel '{e}' nicht in COLUMN_MAP gefunden! Modus abgebrochen.")
return
except Exception as e:
logging.critical(f"FEHLER beim Holen der Spaltenbuchstaben: {e}")
return
all_sheet_updates = []
processed_rows = 0
found_urls = 0
skipped_timestamp_ay = 0
skipped_employee_count = 0
skipped_m_filled_count = 0
now_timestamp_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
for idx, row in enumerate(data_rows):
row_num_in_sheet = idx + header_rows + 1
if row_limit is not None and processed_rows >= row_limit:
logging.info(f"Zeilenlimit ({row_limit}) für durchgeführte Suchen erreicht.")
break
# Prüfe AY Timestamp
ts_ay_val = row[col_indices["AY"]] if len(row) > col_indices["AY"] else ""
if ts_ay_val and ts_ay_val.strip():
skipped_timestamp_ay += 1
continue
try:
# Mitarbeiterzahl prüfen
ma_val_str = row[col_indices["K"]] if len(row) > col_indices["K"] else "0"
try:
ma_val_str_cleaned = re.sub(r"[^\d]", "", ma_val_str)
ma_val = int(ma_val_str_cleaned) if ma_val_str_cleaned else 0
except ValueError: ma_val = 0
if ma_val <= min_employees:
skipped_employee_count += 1
continue
# Wiki URL (M) prüfen
m_value = row[col_indices["M"]] if len(row) > col_indices["M"] else ""
if m_value and m_value.strip().lower() != "k.a.":
skipped_m_filled_count += 1
continue
# Kandidat gefunden
company_name = row[col_indices["B"]] if len(row) > col_indices["B"] else ""
if not company_name:
logging.warning(f"Zeile {row_num_in_sheet}: Übersprungen, kein Firmenname für Suche vorhanden.")
continue
# SerpAPI Suche
logging.info(f"Zeile {row_num_in_sheet}: Suche Wiki-URL für '{company_name}' (MA: {ma_val})...")
wiki_url_found = serp_wikipedia_lookup(company_name)
processed_rows += 1
time.sleep(1.5)
# Updates vorbereiten
# Timestamp AY IMMER setzen
row_updates = [{'range': f'{col_letters["AY"]}{row_num_in_sheet}', 'values': [[now_timestamp_str]]}]
if wiki_url_found:
logging.info(f" -> URL gefunden: {wiki_url_found}. Bereite Update vor (Setze M, A; Lösche N-V, AN, AO, AP, AX).")
found_urls += 1
# Zusätzliche Updates für gefundene URL
row_updates.extend([
{'range': f'{col_letters["M"]}{row_num_in_sheet}', 'values': [[wiki_url_found]]}, # URL setzen
{'range': f'{col_letters["A"]}{row_num_in_sheet}', 'values': [['x']]}, # ReEval Flag
# --- Spalten leeren ---
{'range': f'{col_letters["N"]}{row_num_in_sheet}', 'values': [['']]},
{'range': f'{col_letters["O"]}{row_num_in_sheet}', 'values': [['']]},
{'range': f'{col_letters["P"]}{row_num_in_sheet}', 'values': [['']]},
{'range': f'{col_letters["Q"]}{row_num_in_sheet}', 'values': [['']]},
{'range': f'{col_letters["R"]}{row_num_in_sheet}', 'values': [['']]},
{'range': f'{col_letters["S"]}{row_num_in_sheet}', 'values': [['']]},
{'range': f'{col_letters["T"]}{row_num_in_sheet}', 'values': [['']]},
{'range': f'{col_letters["U"]}{row_num_in_sheet}', 'values': [['']]},
{'range': f'{col_letters["V"]}{row_num_in_sheet}', 'values': [['']]},
{'range': f'{col_letters["AN"]}{row_num_in_sheet}', 'values': [['']]},
{'range': f'{col_letters["AO"]}{row_num_in_sheet}', 'values': [['']]},
{'range': f'{col_letters["AP"]}{row_num_in_sheet}', 'values': [['']]},
{'range': f'{col_letters["AX"]}{row_num_in_sheet}', 'values': [['']]}
])
else:
logging.info(f" -> Keine Wiki-URL für '{company_name}' via SerpAPI gefunden.")
# Nur AY Timestamp wird geschrieben
all_sheet_updates.extend(row_updates)
except Exception as e:
logging.exception(f"Unerwarteter Fehler bei Verarbeitung von Zeile {row_num_in_sheet}: {e}")
continue
# --- Batch Update am Ende ---
if all_sheet_updates:
logging.info(f"Sende Batch-Update für {processed_rows} geprüfte Zeilen ({found_urls} URLs gefunden, {len(all_sheet_updates)} Zellen)...")
success = sheet_handler.batch_update_cells(all_sheet_updates)
if success:
logging.info(f"Sheet-Update für 'find_wiki_serp' erfolgreich.")
else:
logging.info("Keine Zeilen gefunden, für die eine SerpAPI Wiki-Suche durchgeführt werden musste/konnte.")
logging.info(f"Modus 'find_wiki_serp' abgeschlossen.")
logging.info(f" Durchgeführte Suchen in diesem Lauf: {processed_rows}")
logging.info(f" Gefundene & eingetragene URLs: {found_urls}")
logging.info(f" Übersprungen (AY bereits gesetzt): {skipped_timestamp_ay}")
logging.info(f" Übersprungen (MA <= {min_employees}): {skipped_employee_count}")
logging.info(f" Übersprungen (M bereits gefüllt): {skipped_m_filled_count}")
def prepare_data_for_modeling(sheet_handler):
"""
Lädt Daten aus dem Google Sheet, bereitet sie für das Decision Tree Modell vor:
- Wählt relevante Spalten aus.
- Konsolidiert Umsatz/Mitarbeiter (Wiki > CRM Priorität).
- Filtert nach gültiger Technikerzahl (> 0).
- Erstellt die Zielvariable (Techniker-Bucket).
- Bereitet Features auf (One-Hot Encoding für Branche).
- Behält NaNs in numerischen Features für spätere Imputation.
Args:
sheet_handler (GoogleSheetHandler): Instanz mit geladenen Sheet-Daten.
Returns:
pandas.DataFrame: Vorbereiteter DataFrame für Training/Test-Split,
oder None bei Fehlern.
"""
logging.info("Starte Datenvorbereitung für Modellierung...")
try:
# --- 1. Daten laden & Spalten auswählen ---
all_data = sheet_handler.get_all_data_with_headers()
if len(all_data) <= 5: # Annahme: 5 Header-Zeilen
logging.error("Fehler: Nicht genügend Datenzeilen im Sheet gefunden für Modellierung.")
return None
headers = all_data[0] # Nimm die erste Zeile als Header für Pandas
data_rows = all_data[5:] # Daten ohne die ersten 5 Header-Zeilen
# Erstelle DataFrame
df = pd.DataFrame(data_rows, columns=headers)
logging.info(f"DataFrame für Modellierung erstellt mit {len(df)} Zeilen und {len(df.columns)} Spalten.")
# Wähle benötigte Spalten aus ( passe die Schlüssel an deine COLUMN_MAP an!)
required_cols_keys = [
"CRM Name", # Zur Identifikation, wird später entfernt
"CRM Branche",
"CRM Umsatz",
"Wiki Umsatz",
"CRM Anzahl Mitarbeiter",
"Wiki Mitarbeiter",
"CRM Anzahl Techniker" # ÄNDERE DIESEN SCHLÜSSEL, falls die bekannte Zahl woanders steht!
]
try:
# Verwende direkt die Schlüssel als Spaltennamen (Annahme)
# oder implementiere hier eine robustere Zuordnung über Header
df_subset = df[required_cols_keys].copy() # Kopie erstellen
except KeyError as e:
logging.error(f"FEHLER: Benötigte Spalte nicht im DataFrame gefunden: {e}. Verfügbare Spalten: {list(df.columns)}")
return None
logging.info(f"Benötigte Spalten für Modellierung ausgewählt.")
# --- 2. Features konsolidieren (Umsatz, Mitarbeiter) ---
def get_valid_numeric(value_str):
# (Implementierung wie gehabt, aber mit Logging bei Fehlern)
if value_str is None or pd.isna(value_str) or str(value_str).strip() == '': return np.nan
original_value = value_str # Für Logging speichern
try:
cleaned_str = str(value_str).replace('.', '').replace(',', '.') # Tausender raus, Komma zu Punkt
val = float(cleaned_str)
return val if val > 0 else np.nan # Nur Werte > 0 sind gültig
except (ValueError, TypeError):
# Logging auf DEBUG-Level, da dies häufig vorkommen kann
logging.debug(f"Konntze Wert '{original_value}' nicht direkt in Float umwandeln.")
# Fallback über extract_numeric_value (falls vorhanden und float zurückgibt)
# try:
# num_val = extract_numeric_value(str(value_str)) # Annahme: gibt float/int oder Exception/None/NaN zurück
# if isinstance(num_val, (int, float)) and num_val > 0: return float(num_val)
# except Exception: pass # Fehler ignorieren, Fallback weiter unten
# --- VEREINFACHTER FALLBACK ---
cleaned_str = re.sub(r'[^\d.]', '', str(value_str)) # Nur Ziffern und Punkt behalten
if not cleaned_str: return np.nan
try:
val = float(cleaned_str)
return val if val > 0 else np.nan
except ValueError:
logging.debug(f"Konntze auch bereinigten String '{cleaned_str}' aus '{original_value}' nicht umwandeln.")
return np.nan
cols_to_process = {
'Umsatz': ('Wiki Umsatz', 'CRM Umsatz', 'Finaler_Umsatz'),
'Mitarbeiter': ('Wiki Mitarbeiter', 'CRM Anzahl Mitarbeiter', 'Finaler_Mitarbeiter')
}
for base_name, (wiki_col, crm_col, final_col) in cols_to_process.items():
logging.info(f"Verarbeite und konsolidiere '{base_name}' (Priorität: Wiki > CRM)...")
wiki_numeric = df_subset[wiki_col].apply(get_valid_numeric)
crm_numeric = df_subset[crm_col].apply(get_valid_numeric)
df_subset[final_col] = np.where(
wiki_numeric.notna(), # & (wiki_numeric > 0) ist durch get_valid_numeric abgedeckt
wiki_numeric,
np.where(
crm_numeric.notna(), # & (crm_numeric > 0)
crm_numeric,
np.nan
)
)
# Info-Log über Ergebnis
logging.info(f" -> {df_subset[final_col].notna().sum()} gültige '{final_col}' Werte erstellt.")
# --- 3. Zielvariable vorbereiten (Technikerzahl) ---
techniker_col = "CRM Anzahl Techniker" # ÄNDERE DAS WENN NÖTIG!
logging.info(f"Verarbeite Zielvariable '{techniker_col}'...")
# Konvertiere zu Numerisch (Fehler -> NaN)
df_subset['Anzahl_Servicetechniker_Numeric'] = pd.to_numeric(df_subset[techniker_col], errors='coerce')
# Filtere Zeilen: Behalte nur die mit gültiger, positiver Technikerzahl
initial_rows = len(df_subset)
df_filtered = df_subset[
df_subset['Anzahl_Servicetechniker_Numeric'].notna() &
(df_subset['Anzahl_Servicetechniker_Numeric'] > 0)
].copy()
filtered_rows = len(df_filtered)
removed_rows = initial_rows - filtered_rows
# Info, wenn Zeilen entfernt wurden
if removed_rows > 0:
logging.info(f"{removed_rows} Zeilen entfernt aufgrund fehlender/ungültiger Technikerzahl (Wert <= 0 oder nicht numerisch).")
logging.info(f"Verbleibende Zeilen für Modellierung: {filtered_rows}")
if filtered_rows == 0:
logging.error("FEHLER: Keine Zeilen mit gültiger Technikerzahl (>0) übrig für Modellierung!")
return None
# --- 4. Techniker-Buckets erstellen ---
bins = [-1, 0, 19, 49, 99, 249, 499, float('inf')] # -1 um 0 einzuschließen (obwohl wir >0 filtern)
labels = ['Bucket_1_(0)', 'Bucket_2_(<20)', 'Bucket_3_(<50)', 'Bucket_4_(<100)', 'Bucket_5_(<250)', 'Bucket_6_(<500)', 'Bucket_7_(>499)'] # Namen angepasst
df_filtered['Techniker_Bucket'] = pd.cut(
df_filtered['Anzahl_Servicetechniker_Numeric'],
bins=bins,
labels=labels,
right=True # 19 gehört zu <20 etc.
)
logging.info("Techniker-Buckets erstellt.")
# Verteilung als Debug-Info
logging.debug(f"Verteilung der Buckets:\n{df_filtered['Techniker_Bucket'].value_counts(normalize=True).round(3)}")
# --- 5. Kategoriale Features vorbereiten (Branche) ---
branche_col = "CRM Branche" # Annahme: CRM Branche ist die zu verwendende
logging.info(f"Verarbeite kategoriales Feature '{branche_col}' für One-Hot Encoding...")
# Stelle sicher, dass die Spalte String ist und fülle evtl. NaNs
df_filtered[branche_col] = df_filtered[branche_col].astype(str).fillna('Unbekannt').str.strip() # .str.strip() hinzugefügt
# One-Hot Encoding
df_encoded = pd.get_dummies(df_filtered, columns=[branche_col], prefix='Branche', dummy_na=False) # dummy_na=False
logging.info(f"One-Hot Encoding für '{branche_col}' durchgeführt. Neue Spaltenanzahl: {len(df_encoded.columns)}")
# --- 6. Finale Auswahl der Features für das Modell ---
feature_columns = [col for col in df_encoded.columns if col.startswith('Branche_')]
feature_columns.extend(['Finaler_Umsatz', 'Finaler_Mitarbeiter'])
target_column = 'Techniker_Bucket'
# Erstelle den finalen DataFrame (nur benötigte Spalten)
# Behalte Originaldaten für spätere Analyse / Zuordnung
original_data_cols = ['CRM Name', 'Anzahl_Servicetechniker_Numeric']
df_model_ready = df_encoded[original_data_cols + feature_columns + [target_column]].copy()
# Optional: Spalten auf einfache Typen reduzieren (kann Speicher sparen)
for col in ['Finaler_Umsatz', 'Finaler_Mitarbeiter']:
df_model_ready[col] = pd.to_numeric(df_model_ready[col], errors='coerce')
# Reset Index für saubere Verarbeitung im nächsten Schritt
df_model_ready = df_model_ready.reset_index(drop=True)
logging.info("Datenvorbereitung für Modellierung abgeschlossen.")
logging.debug(f"Finaler DataFrame für Modellierung hat {len(df_model_ready)} Zeilen und {len(df_model_ready.columns)} Spalten.")
logging.debug(f"Feature-Spalten: {feature_columns}")
logging.debug(f"Ziel-Spalte: {target_column}")
# WICHTIG: Info über fehlende Werte vor Imputation
nan_counts = df_model_ready[['Finaler_Umsatz', 'Finaler_Mitarbeiter']].isna().sum()
logging.info(f"Fehlende Werte in numerischen Features vor Imputation:\n{nan_counts}")
return df_model_ready
except Exception as e:
# exception loggt automatisch den Traceback
logging.exception(f"FEHLER während der Datenvorbereitung: {e}")
return None
# --- Beispielhafter Aufruf (zum Testen) ---
# if __name__ == '__main__':
# # Annahme: Config, COLUMN_MAP, debug_print sind definiert
# # Annahme: GoogleSheetHandler existiert und verbindet sich
# Config.load_api_keys() # Nur falls für extract_numeric_value nötig
# LOG_FILE = create_log_filename("dataprep_test")
# debug_print("Starte Test der Datenvorbereitung...")
# try:
# sheet_handler_instance = GoogleSheetHandler()
# prepared_df = prepare_data_for_modeling(sheet_handler_instance)
# if prepared_df is not None:
# print("\n--- Vorbereiteter DataFrame (erste 5 Zeilen): ---")
# print(prepared_df.head())
# print("\n--- DataFrame Info: ---")
# prepared_df.info()
# print("\n--- Beschreibung numerischer Features: ---")
# print(prepared_df[['Finaler_Umsatz', 'Finaler_Mitarbeiter']].describe())
# else:
# print("Datenvorbereitung fehlgeschlagen.")
# except Exception as e:
# print(f"Fehler beim Testaufruf: {e}")
# ==================== LOGGING & HELPER FUNCTIONS ====================
LOG_FILE = None # Wird in main() gesetzt
def create_log_filename(mode):
if not os.path.exists(LOG_DIR):
os.makedirs(LOG_DIR)
now = datetime.now().strftime("%d-%m-%Y_%H-%M")
ver_short = Config.VERSION.replace(".", "")
return os.path.join(LOG_DIR, f"{now}_{ver_short}_Modus{mode}.txt")
def debug_print(message):
global LOG_FILE
log_message = f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {message}"
if Config.DEBUG:
print(log_message)
if LOG_FILE:
try:
with open(LOG_FILE, "a", encoding="utf-8") as f:
f.write(log_message + "\n")
except Exception as e:
print(f"[CRITICAL] Log-Schreibfehler: {e}")
def simple_normalize_url(url):
"""Normalisiert URL zu www.domain.tld oder k.A."""
if not url or not isinstance(url, str):
return "k.A."
url = url.strip()
if not url or url.lower() == 'k.a.': # Prüfe auch auf 'k.a.'
return "k.A."
# Falls kein Schema vorhanden ist, hinzufügen (HTTPS bevorzugen)
if not url.lower().startswith(("http://", "https://")):
url = "https://" + url
try:
parsed = urlparse(url)
domain_part = parsed.netloc
if not domain_part: # Wenn netloc leer ist (z.B. bei relativen Pfaden oder mailto:)
logging.warning(f"URL '{url}' konnte nicht sinnvoll geparst werden (leerer netloc).")
return "k.A."
# Entferne einen eventuellen Port (z.B. ":8080")
domain_part = domain_part.split(":", 1)[0]
# Entferne evtl. User/Passwort-Teile (user:pass@domain)
if '@' in domain_part:
domain_part = domain_part.split('@', 1)[1]
# Wandle Punycode (IDN) in Unicode um für Lesbarkeit (optional, aber oft sinnvoll)
try:
domain_part = domain_part.encode('ascii').decode('idna')
except UnicodeDecodeError:
logging.warning(f"Fehler bei IDNA-Dekodierung für Domain '{domain_part}' aus URL '{url}'. Behalte Original.")
# Behalte den ursprünglichen domain_part, wenn Dekodierung fehlschlägt
pass
domain_part = domain_part.lower() # Einheitliche Kleinschreibung
# Wenn die Domain nicht mit "www." beginnt, hinzufügen (außer bei sehr kurzen Domains oder IPs)
if not domain_part.startswith("www.") and '.' in domain_part:
# Ausnahme für IP-Adressen oder ungewöhnliche Namen ohne TLD
if not re.match(r"^\d{1,3}(\.\d{1,3}){3}$", domain_part):
# Prüfe, ob es eine bekannte TLD ist (einfache Prüfung)
if domain_part.split('.')[-1].isalpha() and len(domain_part.split('.')[-1]) > 1:
domain_part = "www." + domain_part
# Optional: Unerwünschte Pfade entfernen (alles nach dem ersten /)
# return domain_part # Gibt nur www.domain.tld zurück
# Oder: Normalisierte URL mit Schema zurückgeben?
# return f"{parsed.scheme}://{domain_part}"
# Aktuell: Nur Domain zurückgeben
return domain_part
except Exception as e:
# Error loggen, da Parsen fehlschlug
logging.error(f"Fehler bei URL-Normalisierung für '{url}': {e}")
return "k.A."
def normalize_string(s):
"""Normalisiert Umlaute und Sonderzeichen."""
if not s or not isinstance(s, str):
return ""
replacements = {
'Ä': 'Ae', 'Ö': 'Oe', 'Ü': 'Ue', 'ß': 'ss', 'ä': 'ae', 'ö': 'oe', 'ü': 'ue',
'À': 'A', 'Á': 'A', 'Â': 'A', 'Ã': 'A', 'Å': 'A', 'Æ': 'AE', 'à': 'a', 'á': 'a', 'â': 'a', 'ã': 'a', 'å': 'a', 'æ': 'ae',
'Ç': 'C', 'ç': 'c', 'È': 'E', 'É': 'E', 'Ê': 'E', 'Ë': 'E', 'è': 'e', 'é': 'e', 'ê': 'e', 'ë': 'e',
'Ì': 'I', 'Í': 'I', 'Î': 'I', 'Ï': 'I', 'ì': 'i', 'í': 'i', 'î': 'i', 'ï': 'i', 'Ñ': 'N', 'ñ': 'n',
'Ò': 'O', 'Ó': 'O', 'Ô': 'O', 'Õ': 'O', 'Ø': 'O', 'ò': 'o', 'ó': 'o', 'ô': 'o', 'õ': 'o', 'ø': 'o', 'Œ': 'OE', 'œ': 'oe',
'Š': 'S', 'š': 's', 'Ž': 'Z', 'ž': 'z', 'Ý': 'Y', 'ý': 'y', 'ÿ': 'y', 'Đ': 'D', 'đ': 'd',
'č': 'c', 'Č': 'C', 'ć': 'c', 'Ć': 'C', 'ł': 'l', 'Ł': 'L', 'ğ': 'g', 'Ğ': 'G', 'ş': 's', 'Ş': 'S',
'ă': 'a', 'Ă': 'A', 'ı': 'i', 'İ': 'I', 'ň': 'n', 'Ň': 'N', 'ř': 'r', 'Ř': 'R',
'ő': 'o', 'Ő': 'O', 'ű': 'u', 'Ű': 'U', 'ț': 't', 'Ț': 'T', 'ș': 's', 'Ș': 'S'
}
# unicodedata Normalisierung zuerst (kann einige Akzente entfernen)
try:
# Versuche NFKD Normalisierung, um Kompatibilitätszeichen zu zerlegen
s = unicodedata.normalize('NFKD', s).encode('ascii', 'ignore').decode('ascii')
except:
# Fallback, wenn NFKD fehlschlägt (sollte selten sein)
pass
# Dann manuelle Ersetzungen
for src, target in replacements.items():
s = s.replace(src, target)
return s
def clean_text(text):
"""Bereinigt Text von Wikipedia etc. (Unicode, Referenzen, Whitespace)."""
if text is None: return "k.A." # Behandle None explizit
try:
text = str(text) # Sicherstellen, dass es ein String ist
if not text.strip(): return "k.A." # Leere oder nur Whitespace-Strings
# Normalisiert Whitespace, Ligaturen etc.
# NFKC ist oft aggressiver, NFKD zerlegt mehr, NFD ist oft gut für Akzententfernung
text = unicodedata.normalize("NFC", text) # NFC ist oft ein guter Kompromiss
# Entfernt Referenz-Tags wie [1], [2], [Bearbeiten | Quelltext bearbeiten] etc.
text = re.sub(r'\[\d+\]', '', text)
text = re.sub(r'\[Bearbeiten\s*\|\s*Quelltext bearbeiten\]', '', text, flags=re.IGNORECASE)
# Ersetzt multiple Leerzeichen/Tabs/Newlines durch ein einzelnes Leerzeichen
text = re.sub(r'\s+', ' ', text).strip()
# Wenn nach Bereinigung leer, gib k.A. zurück
return text if text else "k.A."
except Exception as e:
# Fehlermeldung beim Bereinigen
logging.error(f"Fehler bei clean_text für Input '{str(text)[:50]}...': {e}")
return "k.A."
def normalize_company_name(name):
"""Entfernt Rechtsformzusätze etc. für Vergleiche."""
if not name: return ""
name = clean_text(name) # Vorab bereinigen
# Umfassendere Liste von Rechtsformen und Zusätzen
forms = [
r'gmbh', r'ges\.?\s*m\.?\s*b\.?\s*h\.?', r'gesellschaft mit beschränkter haftung',
r'ug', r'u\.g\.', r'unternehmergesellschaft', r'haftungsbeschränkt',
r'ag', r'a\.g\.', r'aktiengesellschaft',
r'ohg', r'o\.h\.g\.', r'offene handelsgesellschaft',
r'kg', r'k\.g\.', r'kommanditgesellschaft',
r'gmbh\s*&\s*co\.?\s*kg', r'ges\.?\s*m\.?\s*b\.?\s*h\.?\s*&\s*co\.?\s*k\.g\.?',
r'ag\s*&\s*co\.?\s*kg', r'a\.g\.?\s*&\s*co\.?\s*k\.g\.?',
r'e\.k\.', r'e\.kfm\.', r'e\.kfr\.', r'eingetragene[rn]? kauffrau', r'eingetragene[rn]? kaufmann',
r'ltd\.?', r'limited',
r'ltd\s*&\s*co\.?\s*kg',
r's\.?a\.?r\.?l\.?', r'sàrl', r'sagl',
r's\.?a\.?', r'société anonyme', r'sociedad anónima',
r's\.?p\.?a\.?', r'società per azioni',
r'b\.?v\.?', r'besloten vennootschap',
r'n\.?v\.?', r'naamloze vennootschap',
r'plc\.?', r'public limited company',
r'inc\.?', r'incorporated',
r'corp\.?', r'corporation',
r'llc\.?', r'limited liability company',
r'kgaa', r'kommanditgesellschaft auf aktien',
r'se', r'societas europaea',
r'e\.?g\.?', r'eingetragene genossenschaft', r'genossenschaft', r'genmbh',
r'e\.?v\.?', r'eingetragener verein', r'verein',
r'stiftung', r'ggmbh', r'gemeinnützige gmbh', r'gug',
r'partg', r'partnerschaftsgesellschaft', r'partgmbb',
r'og', r'o\.g\.', r'offene gesellschaft',
r'e\.u\.', r'eingetragenes unternehmen',
r'ges\.?n\.?b\.?r\.?', r'gesellschaft nach bürgerlichem recht',
r'kollektivgesellschaft', r'einzelfirma',
# Zusätzliche generische Begriffe am Ende
r'gruppe', r'holding', r'international', r'systeme', r'technik', r'logistik',
r'solutions', r'services', r'management', r'consulting', r'produktion',
r'vertrieb', r'entwicklung', r'maschinenbau', r'anlagenbau'
]
# Pattern für ganze Wörter (case-insensitive)
pattern = r'\b(' + '|'.join(forms) + r')\b'
normalized = re.sub(pattern, '', name, flags=re.IGNORECASE)
# Interpunktion entfernen/ersetzen (außer evtl. &)
normalized = re.sub(r'[.,;:]', '', normalized)
normalized = re.sub(r'[\-/]', ' ', normalized) # Bindestriche etc. durch Leerzeichen ersetzen
normalized = re.sub(r'\s+', ' ', normalized).strip() # Multiple Leerzeichen reduzieren
return normalized.lower()
@retry_on_failure # API Calls können fehlschlagen
def is_valid_wikipedia_article_url(wiki_url):
"""
Prüft über die MediaWiki API, ob eine gegebene Wikipedia-URL
auf einen existierenden Artikel verweist (und keine Weiterleitung/Begriffsklärung ist).
Args:
wiki_url (str): Die zu prüfende Wikipedia URL.
Returns:
bool: True, wenn es ein valider Artikel zu sein scheint, sonst False.
"""
if not wiki_url or not isinstance(wiki_url, str) or not wiki_url.lower().startswith(("http://", "https://")) or "wikipedia.org/wiki/" not in wiki_url.lower(): # lower() für Robustheit
logging.debug(f"is_valid_wikipedia_article_url: Ungültiges Format oder keine Wikipedia-URL: '{wiki_url}'")
return False
title = "URL_PARSE_ERROR" # Default für Logging
try:
# Extrahiere den Artikel-Titel aus der URL
title_part = wiki_url.split('/wiki/', 1)[1]
# Dekodiere URL-kodierte Zeichen (z.B. %C3%BC -> ü)
title = unquote(title_part)
# Ersetze Unterstriche durch Leerzeichen für die API-Suche
title = title.replace('_', ' ')
# Baue die API URL (für deutsche Wikipedia)
api_url = "https://de.wikipedia.org/w/api.php"
params = {
"action": "query",
"titles": title,
"format": "json",
"formatversion": 2, # Moderneres JSON-Format
"prop": "info|pageprops", # Info und Page Properties abfragen
"redirects": 1 # Folge Weiterleitungen
}
logging.debug(f"is_valid_wikipedia_article_url: Prüfe Titel '{title}' via MediaWiki API...")
# Führe den API Call durch
response = requests.get(api_url, params=params, timeout=10, headers={'User-Agent': Config.USER_AGENT}) # Timeout und UserAgent
response.raise_for_status()
data = response.json()
logging.debug(f" -> API Antwort für '{title}': {str(data)[:200]}...") # Logge Anfang der Antwort
# Analysiere die Antwort
if 'query' in data and 'pages' in data['query']:
pages = data['query']['pages']
if pages:
page_info = pages[0] # Nimm die erste (und einzige) Seite
# Prüfe auf 'missing': Seite existiert nicht
if page_info.get('missing', False):
logging.debug(f" API Check für '{title}': Seite fehlt (missing=True).")
return False
# Prüfe auf 'invalid': Titel ist ungültig
if page_info.get('invalid', False):
logging.debug(f" API Check für '{title}': Titel ungültig (invalid=True).")
return False
# Prüfe auf 'disambiguation': Ist eine Begriffsklärungsseite
if 'pageprops' in page_info and 'disambiguation' in page_info['pageprops']:
logging.debug(f" API Check für '{title}': Seite ist eine Begriffsklärung.")
return False
# Wenn nichts davon zutrifft, scheint es ein valider Artikel zu sein
logging.info(f" API Check für '{title}': Scheint ein valider Artikel zu sein.")
return True
else:
# Warnung, da unerwartet
logging.warning(f" API Check für '{title}': Leere 'pages'-Liste in API-Antwort.")
return False # Unerwartete Antwort
else:
# Warnung, da unerwartet
logging.warning(f" API Check für '{title}': Unerwartetes Format der API-Antwort (fehlendes 'query' oder 'pages').")
return False
except requests.exceptions.RequestException as e:
# Error bei Netzwerkproblemen
logging.error(f" API Check für '{title}': Netzwerkfehler - {e}")
# Fehler weitergeben, damit retry_on_failure greift
raise e
except Exception as e:
# Error bei anderen Problemen
logging.error(f" API Check für '{title}': Allgemeiner Fehler - {e}")
# Im Zweifel als ungültig werten, aber keinen Fehler für Retry werfen? Oder doch?
# Besser Fehler weitergeben, falls es ein temporäres Problem ist.
raise e # Fehler weitergeben
# NEUE Funktion für Wiki-Updates basierend auf ChatGPT Vorschlägen
# NEUE Funktion für Wiki-Updates basierend auf ChatGPT Vorschlägen (mit Status-Update in S)
# Komplette Funktion process_wiki_updates_from_chatgpt (Syntaxfehler behoben)
def process_wiki_updates_from_chatgpt(sheet_handler, data_processor, row_limit=None):
"""
Identifiziert Zeilen (S nicht OK/Updated/Copied/Invalid), prüft ob U eine *valide* und *andere* Wiki-URL ist.
- Wenn ja: Kopiert U->M, markiert S='X (URL Copied)', U='URL übernommen', löscht TS/Version, setzt ReEval-Flag A.
- Wenn nein (U keine URL, U==M, oder U ungültig): LÖSCHT den Inhalt von U und markiert S als 'X (Invalid Suggestion)'.
Verarbeitet maximal row_limit Zeilen.
"""
logging.info("Starte Modus: Wiki-Updates (URL-Validierung & Löschen ungültiger Vorschläge)...")
if row_limit is not None:
logging.info(f"Zeilenlimit für diesen Lauf: {row_limit}")
if not sheet_handler.load_data(): return # load_data loggt intern
all_data = sheet_handler.get_all_data_with_headers()
if not all_data or len(all_data) <= 5:
logging.warning("Keine oder zu wenige Daten im Sheet für Wiki-Updates gefunden.")
return
header_rows = 5
data_rows = all_data[header_rows:]
# --- Indizes holen ---
required_keys = [
"Chat Wiki Konsistenzprüfung", "Chat Vorschlag Wiki Artikel", "Wiki URL",
"Wikipedia Timestamp", "Wiki Verif. Timestamp", "Timestamp letzte Prüfung", "Version",
"ReEval Flag"
]
col_indices = {}
all_keys_found = True
for key in required_keys:
idx = COLUMN_MAP.get(key)
col_indices[key] = idx
if idx is None:
logging.error(f"FEHLER: Schlüssel '{key}' für Spaltenindex fehlt in COLUMN_MAP!")
all_keys_found = False
if not all_keys_found:
logging.error("Breche Wiki-Updates ab, da Spaltenindizes fehlen.")
return
# --- Ende Indizes holen ---
all_sheet_updates = []
processed_rows_count = 0 # Zählt Zeilen, die geprüft werden
updated_url_count = 0 # Zählt Zeilen, wo URL kopiert wurde
cleared_suggestion_count = 0 # Zählt Zeilen, wo Vorschlag gelöscht wurde
# Iteriere durch die Datenzeilen
for idx, row in enumerate(data_rows):
row_num_in_sheet = idx + header_rows + 1
if row_limit is not None and processed_rows_count >= row_limit:
logging.info(f"Zeilenlimit ({row_limit}) erreicht.")
break
# --- Hilfsfunktion für sicheren Zugriff ---
def get_value(key):
index = col_indices.get(key)
if index is not None and len(row) > index: return row[index]
# Logge nur auf Debug-Level, wenn ein Wert fehlt
# logging.debug(f"Zeile {row_num_in_sheet}: Wert für '{key}' nicht vorhanden (Index: {index}, Zeilenlänge: {len(row)}).")
return ""
konsistenz_s = get_value("Chat Wiki Konsistenzprüfung")
vorschlag_u = get_value("Chat Vorschlag Wiki Artikel")
url_m = get_value("Wiki URL")
# Bedingungen prüfen
konsistenz_s_upper = konsistenz_s.strip().upper()
vorschlag_u_cleaned = vorschlag_u.strip()
url_m_cleaned = url_m.strip()
# Zustand, der eine Prüfung/Aktion auslöst:
# Status S ist gesetzt UND NICHT einer der End-/Bearbeitungszustände
is_candidate_for_check = konsistenz_s_upper and konsistenz_s_upper not in ["OK", "X (UPDATED)", "X (URL COPIED)", "X (INVALID SUGGESTION)"]
if is_candidate_for_check:
logging.debug(f"Zeile {row_num_in_sheet}: Kandidat für Wiki-Update-Prüfung (Status S = '{konsistenz_s}'). Vorschlag U = '{vorschlag_u_cleaned}'")
processed_rows_count += 1 # Zähle geprüfte Zeile
# Prüfe, ob Vorschlag U eine valide URL ist und sich von M unterscheidet
is_update_candidate = False
new_url = ""
condition2_u_is_url = vorschlag_u_cleaned.lower().startswith(("http://", "https://")) and "wikipedia.org/wiki/" in vorschlag_u_cleaned.lower()
if condition2_u_is_url:
new_url = vorschlag_u_cleaned
condition3_u_differs_m = new_url != url_m_cleaned
if condition3_u_differs_m:
logging.debug(f" -> Prüfe Validität der neuen URL: {new_url}...")
condition4_u_is_valid = is_valid_wikipedia_article_url(new_url) # Nutzt die überarbeitete Funktion
if condition4_u_is_valid:
is_update_candidate = True
else:
logging.debug(f" -> URL '{new_url}' ist KEIN valider Artikel laut API Check.")
else:
logging.debug(f" -> Vorschlag U ist identisch mit URL M.")
else:
logging.debug(f" -> Vorschlag U ist keine Wikipedia URL.")
# --- Verarbeitung des Kandidaten ODER Löschen des Vorschlags ---
if is_update_candidate:
# Fall 1: Gültiges Update durchführen
logging.info(f"Zeile {row_num_in_sheet}: Update-Kandidat VALIDIERUNG ERFOLGREICH. Setze ReEval-Flag 'x' und bereite Updates vor für URL: {new_url}")
updated_url_count += 1
# Updates sammeln (M, S, U, Timestamps/Version löschen, A setzen)
m_l=sheet_handler._get_col_letter(col_indices["Wiki URL"]+1); s_l=sheet_handler._get_col_letter(col_indices["Chat Wiki Konsistenzprüfung"]+1); u_l=sheet_handler._get_col_letter(col_indices["Chat Vorschlag Wiki Artikel"]+1)
an_l=sheet_handler._get_col_letter(col_indices["Wikipedia Timestamp"]+1); ax_l=sheet_handler._get_col_letter(col_indices["Wiki Verif. Timestamp"]+1); ao_l=sheet_handler._get_col_letter(col_indices["Timestamp letzte Prüfung"]+1)
ap_l=sheet_handler._get_col_letter(col_indices["Version"]+1); a_l=sheet_handler._get_col_letter(col_indices["ReEval Flag"]+1)
row_updates = [
{'range': f'{m_l}{row_num_in_sheet}', 'values': [[new_url]]},
{'range': f'{s_l}{row_num_in_sheet}', 'values': [["X (URL Copied)"]]}, # Neuer Status
{'range': f'{u_l}{row_num_in_sheet}', 'values': [["URL übernommen"]]}, # Info in U
# Timestamps löschen, damit reeval greift
{'range': f'{an_l}{row_num_in_sheet}', 'values': [[""]]},
{'range': f'{ax_l}{row_num_in_sheet}', 'values': [[""]]},
{'range': f'{ao_l}{row_num_in_sheet}', 'values': [[""]]},
{'range': f'{ap_l}{row_num_in_sheet}', 'values': [[""]]},
{'range': f'{a_l}{row_num_in_sheet}', 'values': [["x"]]}, # ReEval Flag setzen!
]
all_sheet_updates.extend(row_updates)
else:
# Fall 2: Ungültigen Vorschlag löschen/markieren
logging.info(f"Zeile {row_num_in_sheet}: Status S ('{konsistenz_s}') erfordert Prüfung, aber Vorschlag U ('{vorschlag_u_cleaned}') ist ungültig/identisch. Lösche U und setze Status S.")
cleared_suggestion_count += 1
s_l=sheet_handler._get_col_letter(col_indices["Chat Wiki Konsistenzprüfung"]+1)
u_l=sheet_handler._get_col_letter(col_indices["Chat Vorschlag Wiki Artikel"]+1)
row_updates = [
{'range': f'{s_l}{row_num_in_sheet}', 'values': [["X (Invalid Suggestion)"]]}, # Neuer Status
{'range': f'{u_l}{row_num_in_sheet}', 'values': [[""]]} # Vorschlag löschen
]
all_sheet_updates.extend(row_updates)
# Kein ReEval-Flag setzen
# --- Batch Update am Ende ---
if all_sheet_updates:
# Info-Log über Anzahl der Updates
logging.info(f"Sende Batch-Update für {processed_rows_count} geprüfte Zeilen ({len(all_sheet_updates)} Zellen)...")
success = sheet_handler.batch_update_cells(all_sheet_updates) # Nutzt intern Logging
if success:
logging.info(f"Sheet-Update für Wiki-Updates erfolgreich.")
# Der else-Fall wird von batch_update_cells geloggt
else:
logging.info("Keine Zeilen gefunden, die eine Wiki-URL-Korrektur oder Vorschlagsbereinigung benötigen.")
logging.info(f"Wiki-Updates abgeschlossen. {processed_rows_count} Zeilen geprüft. {updated_url_count} URLs kopiert & für ReEval markiert, {cleared_suggestion_count} ungültige Vorschläge gelöscht/markiert.")
def extract_numeric_value(raw_value, is_umsatz=False):
"""Extrahiert und normalisiert Zahlenwerte (Umsatz in Mio, Mitarbeiter).
Berücksichtigt jetzt auch Apostroph (') als Tausendertrenner."""
if not raw_value: return "k.A."
raw_value_str = str(raw_value)
if not raw_value_str.strip() or raw_value_str.strip().lower() in ['k.a.', 'n/a', '-']:
return "k.A."
processed_value = clean_text(raw_value_str)
if processed_value == "k.A.": return "k.A."
logging.debug(f"extract_numeric_value: Verarbeite Wert: '{processed_value}' (is_umsatz={is_umsatz})")
# --- Anpassung hier: Entferne auch Apostroph ---
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()
# Entferne Punkte UND Apostrophe als Tausendertrenner
processed_value = processed_value.replace('.', '').replace("'", "")
# Ersetze Komma durch Punkt für Dezimaltrennung
processed_value = processed_value.replace(',', '.')
# --- Ende Anpassung ---
match = re.search(r'([\d.]+)', processed_value)
if not match:
logging.warning(f"Keine numerischen Zeichen gefunden nach Bereinigung von: '{raw_value_str}' -> Ergibt: '{processed_value}'")
return "k.A."
num_str = match.group(1)
try:
# Versuche, leere Strings oder nur '.' abzufangen
if not num_str or num_str == '.': raise ValueError("Leerer oder ungültiger Zahlenstring")
num = float(num_str)
except ValueError as e:
logging.error(f"Fehler bei Float-Umwandlung des extrahierten Strings '{num_str}' (aus '{processed_value}'): {e}")
return "k.A."
original_lower = raw_value_str.lower()
multiplier = 1.0
unit_found = None
if "mrd" in original_lower or "milliarden" in original_lower or "billion" in original_lower:
multiplier = 1000000000.0
unit_found = "Mrd"
elif "mio" in original_lower or "millionen" in original_lower or "mill." in original_lower:
multiplier = 1000000.0
unit_found = "Mio"
elif "tsd" in original_lower or "tausend" in original_lower:
multiplier = 1000.0
unit_found = "Tsd"
num = num * multiplier
if unit_found:
logging.debug(f" -> Multiplikator '{unit_found}' ({multiplier}) angewendet, Ergebnis: {num}")
if is_umsatz:
umsatz_mio = round(num / 1000000.0)
logging.debug(f" -> Finaler Umsatz (Mio): {umsatz_mio}")
return str(int(umsatz_mio))
else:
mitarbeiter_int = round(num)
logging.debug(f" -> Finale Mitarbeiterzahl: {mitarbeiter_int}")
return str(int(mitarbeiter_int))
def get_gender(firstname):
"""Ermittelt Geschlecht via gender-guesser und Fallback Genderize API."""
if not firstname or not isinstance(firstname, str): return "unknown"
# Nimm nur den ersten Teil des Vornamens und bereinige ihn
firstname_clean = firstname.strip().split(" ")[0]
if not firstname_clean: return "unknown"
# 1. Versuch: gender-guesser
try:
d = gender.Detector(case_sensitive=False)
# Länderkennung kann helfen, ist aber nicht immer nötig/korrekt
result_gg = d.get_gender(firstname_clean) # Ohne Land versuchen? Oder 'germany'?
logging.debug(f"GenderGuesser für '{firstname_clean}': {result_gg}")
except Exception as e_gg:
logging.warning(f"Fehler bei gender-guesser für '{firstname_clean}': {e_gg}")
result_gg = "unknown" # Fallback bei Fehler
# 2. Fallback: Genderize API (nur wenn gender-guesser unsicher ist)
if result_gg in ["andy", "unknown", "mostly_male", "mostly_female"]:
genderize_key = Config.API_KEYS.get('genderize')
if not genderize_key:
logging.warning("Genderize API-Schlüssel nicht verfügbar, Fallback nicht möglich.")
# Gib das Ergebnis von gender-guesser zurück, wenn es "mostly_" war, sonst unknown
return result_gg if result_gg.startswith("mostly_") else "unknown"
params = {"name": firstname_clean, "apikey": genderize_key, "country_id": "DE"}
try:
logging.debug(f"Genderize API-Anfrage für '{firstname_clean}'...")
response = requests.get("https://api.genderize.io", params=params, timeout=5)
response.raise_for_status() # Fehler bei HTTP-Status != 200
data = response.json()
logging.debug(f" -> Genderize Antwort: {data}")
api_gender = data.get("gender")
probability = data.get("probability", 0)
# Nur bei hoher Sicherheit und wenn Genderize ein Ergebnis liefert
if api_gender and probability and probability > 0.7: # Schwelle ggf. anpassen
logging.debug(f" -> Übernehme Genderize Ergebnis '{api_gender}' (Prob: {probability})")
return api_gender
else:
# Wenn Genderize unsicher ist oder null liefert, nimm gender-guesser Ergebnis (falls mostly_)
logging.debug(f" -> Genderize unsicher/kein Ergebnis. Nutze Fallback: '{result_gg}'")
return result_gg if result_gg.startswith("mostly_") else "unknown"
except requests.exceptions.RequestException as e:
logging.error(f"Fehler bei der Genderize API-Anfrage für '{firstname_clean}': {e}")
# Fallback auf gender-guesser Ergebnis (falls mostly_)
return result_gg if result_gg.startswith("mostly_") else "unknown"
except Exception as e: # Z.B. JSONDecodeError
logging.error(f"Allgemeiner Fehler bei Genderize für '{firstname_clean}': {e}")
# Fallback auf gender-guesser Ergebnis (falls mostly_)
return result_gg if result_gg.startswith("mostly_") else "unknown"
else:
# Wenn gender-guesser sicher war (male, female), gib das Ergebnis direkt zurück
return result_gg
def get_email_address(firstname, lastname, website):
"""Generiert E-Mail: vorname.nachname@domain.tld."""
if not all([firstname, lastname, website]) or not all(isinstance(x, str) for x in [firstname, lastname, website]):
return ""
domain = simple_normalize_url(website)
if domain == "k.A." or not '.' in domain: # Einfache Domain-Validierung
return ""
# Domain von 'www.' befreien, falls simple_normalize_url es nicht schon getan hat
if domain.startswith("www."):
domain = domain[4:]
# Vor- und Nachname normalisieren (Umlaute etc.), Kleinbuchstaben, keine Sonderzeichen außer '.' und '-' erlauben
normalized_first = normalize_string(firstname.lower())
normalized_last = normalize_string(lastname.lower())
# Ersetze Leerzeichen und mehrere Bindestriche durch einen einzelnen Bindestrich
normalized_first = re.sub(r'\s+', '-', normalized_first)
normalized_last = re.sub(r'\s+', '-', normalized_last)
# Entferne alle Zeichen, die nicht alphanumerisch oder Bindestrich sind
normalized_first = re.sub(r'[^\w\-]+', '', normalized_first)
normalized_last = re.sub(r'[^\w\-]+', '', normalized_last)
if normalized_first and normalized_last and domain:
return f"{normalized_first}.{normalized_last}@{domain}"
else:
return ""
def fuzzy_similarity(str1, str2):
"""Berechnet Ähnlichkeit zwischen 0 und 1."""
if not str1 or not str2: return 0.0
return SequenceMatcher(None, str(str1).lower(), str(str2).lower()).ratio()
# ==================== BRANCH MAPPING & SCHEMA ====================
import re # Sicherstellen, dass re importiert ist
# Annahmen:
# - Die globalen Variablen ALLOWED_TARGET_BRANCHES und TARGET_SCHEMA_STRING werden
# durch load_target_schema() korrekt befüllt (enthalten nur Kurzformen).
# - Die Funktion call_openai_chat(prompt, temperature) existiert und funktioniert.
# - Die Funktion debug_print(message) existiert.
# - Die globale Variable Config.API_KEYS['openai'] ist verfügbar.
def evaluate_branche_chatgpt(crm_branche, beschreibung, wiki_branche, wiki_kategorien, website_summary):
"""
Ordnet das Unternehmen basierend auf den angegebenen Informationen exakt einer Branche
aus dem Ziel-Branchenschema (nur Kurzformen) zu. Validiert den ChatGPT-Vorschlag
strikt gegen die erlaubten Kurzformen und führt einen Fallback auf die (extrahierte)
CRM-Kurzform durch, falls der Vorschlag ungültig ist.
Args:
crm_branche (str): Branche laut CRM (kann noch Präfix enthalten).
beschreibung (str): Unternehmensbeschreibung (CRM).
wiki_branche (str): Branche aus Wikipedia (falls vorhanden).
wiki_kategorien (str): Wikipedia-Kategorien.
website_summary (str): Zusammenfassung des Website-Inhalts.
Returns:
dict: Enthält "branch" (die finale, gültige Kurzform oder Fehler),
"consistency" ('ok', 'X', 'fallback_crm_valid', 'fallback_invalid') und
"justification" (Begründung von ChatGPT oder Fallback-Info).
"""
global ALLOWED_TARGET_BRANCHES, TARGET_SCHEMA_STRING
# Grundlegende Prüfung: Ist das Schema überhaupt geladen?
if not ALLOWED_TARGET_BRANCHES:
# Kritischer Fehler, da Kernfunktion nicht möglich
logging.critical("FEHLER in evaluate_branche_chatgpt: Ziel-Branchenschema (ALLOWED_TARGET_BRANCHES) ist leer. Abbruch der Funktion.")
return {"branch": crm_branche, "consistency": "error_schema_missing", "justification": "Fehler: Ziel-Schema nicht geladen"}
# Erstelle Lookup für erlaubte Branches
allowed_branches_lookup = {b.lower(): b for b in ALLOWED_TARGET_BRANCHES}
# --- Prompt für ChatGPT erstellen ---
prompt_parts = [TARGET_SCHEMA_STRING]
prompt_parts.append("\nOrdne das Unternehmen anhand folgender Angaben exakt einer Branche des Ziel-Branchenschemas (Kurzformen) zu:")
# Sammle vorhandene Infos
info_count = 0
if crm_branche and crm_branche != "k.A.": prompt_parts.append(f"- CRM-Branche (Referenz): {crm_branche}"); info_count += 1
if beschreibung and beschreibung != "k.A.": prompt_parts.append(f"- Beschreibung: {beschreibung[:500]}..."); info_count += 1 # Kürzen
if wiki_branche and wiki_branche != "k.A.": prompt_parts.append(f"- Wikipedia-Branche: {wiki_branche[:300]}"); info_count += 1 # Kürzen
if wiki_kategorien and wiki_kategorien != "k.A.": prompt_parts.append(f"- Wikipedia-Kategorien: {wiki_kategorien[:500]}..."); info_count += 1 # Kürzen
if website_summary and website_summary != "k.A.": prompt_parts.append(f"- Website-Zusammenfassung: {website_summary[:500]}..."); info_count += 1 # Kürzen
# Fallback, wenn zu wenige Infos da sind
if info_count < 2: # Mindestens 2 Info-Punkte sollten vorhanden sein
logging.warning("Warnung in evaluate_branche_chatgpt: Zu wenige Informationen (<2) für Branchenevaluierung.")
# Gib ursprüngliche CRM-Branche zurück, aber markiere als Fehler
return {"branch": crm_branche, "consistency": "error_no_info", "justification": "Fehler: Zu wenige Informationen für eine Einschätzung"}
prompt_parts.append("\nWICHTIG: Antworte NUR mit dem exakten Kurznamen einer Branche aus der obigen Liste. Verwende KEINE Präfixe wie 'Hersteller / Produzenten >' oder 'Service provider (Dienstleister) >'.")
prompt_parts.append("\nAntworte ausschließlich im folgenden Format (keine Einleitung, kein Schlusssatz):")
prompt_parts.append("Branche: <Exakter Kurzname der Branche aus der Liste>")
prompt_parts.append("Übereinstimmung: <ok oder X (Vergleich deines Vorschlags mit der extrahierten Kurzform der CRM-Referenz)>")
prompt_parts.append("Begründung: <Sehr kurze Begründung für deinen Branchenvorschlag>")
prompt = "\n".join(prompt_parts)
logging.debug(f"Erstellter Prompt für Branchenevaluierung:\n---\n{prompt}\n---")
# --- ChatGPT aufrufen ---
chat_response = call_openai_chat(prompt, temperature=0.0) # Niedrige Temperatur
if not chat_response:
# Fehler loggen
logging.error("Fehler in evaluate_branche_chatgpt: Keine Antwort von OpenAI erhalten.")
return {"branch": crm_branche, "consistency": "error_api_no_response", "justification": "Fehler: Keine Antwort von API"}
logging.debug(f"OpenAI Antwort für Branchenevaluierung: {chat_response}")
# --- Antwort parsen ---
lines = chat_response.strip().split("\n")
result = {"branch": None, "consistency": None, "justification": ""}
suggested_branch = ""
parsed_branch = False
for line in lines:
line_lower = line.lower()
if line_lower.startswith("branche:"):
suggested_branch = line.split(":", 1)[1].strip().strip('"\'') # Trimme Leerzeichen und Anführungszeichen
parsed_branch = True
elif line_lower.startswith("begründung:"):
result["justification"] = line.split(":", 1)[1].strip()
# 'Übereinstimmung' wird ignoriert und später selbst berechnet
if not parsed_branch or not suggested_branch: # Prüfe, ob Branch geparst wurde UND nicht leer ist
logging.error(f"Fehler in evaluate_branche_chatgpt: Konnte 'Branche:' nicht oder nur leer aus Antwort parsen: {chat_response}")
return {"branch": crm_branche, "consistency": "error_parsing", "justification": f"Fehler: Parsing der API Antwort fehlgeschlagen. Antwort: {chat_response}"}
# --- Validierung des ChatGPT-Vorschlags ---
final_branch = None
suggested_branch_lower = suggested_branch.lower()
if suggested_branch_lower in allowed_branches_lookup:
final_branch = allowed_branches_lookup[suggested_branch_lower] # Nimm korrekte Schreibweise
logging.debug(f"ChatGPT-Branchenvorschlag '{suggested_branch}' ist gültig ('{final_branch}').")
result["consistency"] = "pending_comparison" # Temporärer Status
else:
# --- Fallback-Logik ---
logging.debug(f"ChatGPT-Branchenvorschlag '{suggested_branch}' ist NICHT im Ziel-Schema ({len(ALLOWED_TARGET_BRANCHES)} Einträge). Starte Fallback...")
# Versuche Kurzform aus CRM-Branche zu extrahieren
crm_short_branch = "k.A."
if crm_branche and ">" in crm_branche:
crm_short_branch = crm_branche.split(">", 1)[1].strip()
elif crm_branche and crm_branche != "k.A.":
crm_short_branch = crm_branche.strip()
logging.debug(f" Fallback: Prüfe extrahierte CRM-Kurzform: '{crm_short_branch}'")
crm_short_branch_lower = crm_short_branch.lower()
# Logge nur wenige Lookup-Keys zur Kontrolle
# lookup_keys_sample = list(allowed_branches_lookup.keys())[:5]
# logging.debug(f" -> Prüfe gegen Lookup-Keys (erste 5): {lookup_keys_sample}...")
if crm_short_branch != "k.A." and crm_short_branch_lower in allowed_branches_lookup:
final_branch = allowed_branches_lookup[crm_short_branch_lower]
result["consistency"] = "fallback_crm_valid"
fallback_reason = f"Fallback: Ungültiger ChatGPT-Vorschlag ('{suggested_branch}'). Gültige CRM-Kurzform '{final_branch}' verwendet."
result["justification"] = f"{fallback_reason} (ChatGPT Begründung war: {result.get('justification', 'Keine')})"
# Info statt Debug, da dies eine wichtige Entscheidung ist
logging.info(f"Fallback auf gültige CRM-Kurzform erfolgreich: '{final_branch}'")
else:
# Wenn auch CRM-Kurzform ungültig
final_branch = suggested_branch # Behalte ungültigen Vorschlag temporär
result["consistency"] = "fallback_invalid"
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')})"
# Warnung, da keine gültige Branche gefunden wurde
logging.warning(f"Fallback fehlgeschlagen. Ungültiger Vorschlag: '{final_branch}', Ungültige CRM-Kurzform: '{crm_short_branch}'")
final_branch = "FEHLER - UNGÜLTIGE ZUWEISUNG" # Setze finalen Branch auf Fehler
# Setze den finalen Branch im Ergebnis-Dictionary
result["branch"] = final_branch
# --- Konsistenzprüfung (Finale Bewertung) ---
# Extrahiere CRM-Kurzform für den Vergleich erneut
crm_short_to_compare = "k.A."
if crm_branche and ">" in crm_branche:
crm_short_to_compare = crm_branche.split(">", 1)[1].strip()
elif crm_branche and crm_branche != "k.A.":
crm_short_to_compare = crm_branche.strip()
# Vergleiche finalen Branch (falls nicht FEHLER) mit CRM-Kurzform
if result["branch"] != "FEHLER - UNGÜLTIGE ZUWEISUNG":
if result["branch"].lower() == crm_short_to_compare.lower():
if result["consistency"] == "pending_comparison":
result["consistency"] = "ok"
# Wenn Fallback auf gültige CRM stattfand ('fallback_crm_valid'), bleibt dieser Status.
elif result["consistency"] == "pending_comparison":
# Wenn sie nicht übereinstimmen und kein Fallback stattfand, ist es 'X'.
result["consistency"] = "X"
# Wenn der Status bereits 'fallback_crm_valid' oder 'fallback_invalid' ist oder Branch ein Fehler ist, bleibt er.
# Entferne den temporären Status sicherheitshalber
if result["consistency"] == "pending_comparison":
logging.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
logging.error("Konsistenz blieb unerwartet None, setze auf 'error_unknown_state'.")
result["consistency"] = "error_unknown_state"
# Debug-Ausgabe des finalen Ergebnisses
logging.debug(f"Finale Branch-Evaluation: {result}")
return result
def load_target_schema(csv_filepath=BRANCH_MAPPING_FILE):
"""Lädt Liste erlaubter Ziele (Kurzformen) aus Spalte A der CSV."""
global BRANCH_MAPPING, TARGET_SCHEMA_STRING, ALLOWED_TARGET_BRANCHES
# BRANCH_MAPPING wird nicht mehr benötigt, wenn wir nur die Ziele laden
BRANCH_MAPPING = {}
allowed_branches_set = set()
debug_print(f"Versuche, Ziel-Schema (Kurzformen) aus '{csv_filepath}' Spalte A zu laden...") # NEU
line_count = 0
try:
with open(csv_filepath, encoding="utf-8-sig") as f:
reader = csv.reader(f)
# Optional: Header überspringen
# next(reader, None)
for row in reader:
line_count += 1
if line_count <= 10 or line_count % 100 == 0:
debug_print(f"Schema-Laden: Lese Zeile {line_count}: {row}")
if len(row) >= 1: # Nur Spalte A (Index 0) wird benötigt
target = row[0].strip()
if target: # Nur nicht-leere Einträge hinzufügen
allowed_branches_set.add(target)
if line_count <= 10: # Logge die ersten 10 hinzugefügten
debug_print(f" -> '{target}' zum Set hinzugefügt.")
except FileNotFoundError:
debug_print(f"Fehler: Schema-Datei '{csv_filepath}' nicht gefunden.")
ALLOWED_TARGET_BRANCHES = []
except Exception as e:
debug_print(f"Fehler beim Laden des Ziel-Schemas aus '{csv_filepath}' (Zeile {line_count}): {e}")
ALLOWED_TARGET_BRANCHES = []
ALLOWED_TARGET_BRANCHES = sorted(list(allowed_branches_set), key=str.lower)
debug_print(f"Ziel-Schema geladen. {len(ALLOWED_TARGET_BRANCHES)} eindeutige Zielbranchen gefunden.") # NEU: Zählung der Branches
# Logge die ersten paar geladenen Branches zur Kontrolle
if ALLOWED_TARGET_BRANCHES:
debug_print(f"Erste 10 geladene Zielbranchen: {ALLOWED_TARGET_BRANCHES[:10]}")
schema_lines = ["Ziel-Branchenschema: Folgende Branchenbereiche sind gültig (Kurzformen):"] # Klarstellung
schema_lines.extend(f"- {branch}" for branch in ALLOWED_TARGET_BRANCHES)
schema_lines.append("Bitte ordne das Unternehmen ausschließlich in einen dieser Bereiche ein. Gib NUR den Kurznamen der Branche zurück (keine Präfixe wie 'Hersteller / Produzenten >').") # Strengere Anweisung
TARGET_SCHEMA_STRING = "\n".join(schema_lines)
else:
TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfügbar (Datei leer oder Fehler)."
ALLOWED_TARGET_BRANCHES = []
def map_external_branch(external_branch):
"""
Versucht, eine externe Branchenbezeichnung mithilfe des Mappings in das Ziel-Schema zu überführen.
Nutzt Normalisierung und Teilstring-Matching als Fallback.
"""
if not external_branch or not isinstance(external_branch, str) or not BRANCH_MAPPING:
return external_branch # Gib Original zurück, wenn kein Input oder kein Mapping
norm_external = normalize_string(external_branch).lower()
# 1. Exaktes Match (nach Normalisierung)
if norm_external in BRANCH_MAPPING:
return BRANCH_MAPPING[norm_external]
# 2. Teilstring-Match (prüfe, ob ein Mapping-Key im normalisierten Input enthalten ist)
# Sortiere Keys nach Länge (absteigend), um spezifischere Treffer zu bevorzugen
sorted_keys = sorted(BRANCH_MAPPING.keys(), key=len, reverse=True)
for key in sorted_keys:
if key in norm_external:
debug_print(f"Teilstring-Match für Branche: '{key}' in '{norm_external}' -> '{BRANCH_MAPPING[key]}'")
return BRANCH_MAPPING[key]
# 3. Kein Mapping gefunden
debug_print(f"Kein Mapping für externe Branche '{external_branch}' (normalisiert: '{norm_external}') gefunden.")
return external_branch # Gib Original zurück, wenn kein Mapping passt
# ==================== TOKEN COUNT FUNCTION ====================
@retry_on_failure
def token_count(text):
"""Zählt Tokens via tiktoken oder schätzt über Leerzeichen."""
if not text or not isinstance(text, str): return 0
if tiktoken:
try:
# Cache encoding object per model
if not hasattr(token_count, 'enc_cache'):
token_count.enc_cache = {}
if Config.TOKEN_MODEL not in token_count.enc_cache:
token_count.enc_cache[Config.TOKEN_MODEL] = tiktoken.encoding_for_model(Config.TOKEN_MODEL)
enc = token_count.enc_cache[Config.TOKEN_MODEL]
return len(enc.encode(text))
except Exception as e:
debug_print(f"Fehler beim Token-Counting mit tiktoken für Modell '{Config.TOKEN_MODEL}': {e}")
# Fallback zur Schätzung
return len(text.split())
else:
# Fallback Schätzung
return len(text.split())
# ==================== GOOGLE SHEET HANDLER ====================
# Annahmen:
# - Globale Variablen/Konstanten: retry_on_failure, Config, CREDENTIALS_FILE, Config.SHEET_URL, debug_print, COLUMN_MAP
# - COLUMN_MAP enthält den Schlüssel "Website Scrape Timestamp" mit dem korrekten Index (45)
class GoogleSheetHandler:
def __init__(self):
"""Initialisiert den Handler, verbindet und lädt initiale Daten."""
self.sheet = None
self.sheet_values = []
self.headers = [] # Speichert die erste Zeile als Header-Namen
try:
self._connect()
if self.sheet:
self.load_data() # Erste Datenladung bei Initialisierung
except Exception as e:
debug_print(f"FATAL: Fehler bei Initialisierung von GoogleSheetHandler: {e}")
# Wirft einen Fehler, damit das Hauptprogramm weiß, dass es nicht weitergehen kann
raise ConnectionError(f"Google Sheet Handler Init failed: {e}")
# retry_on_failure Decorator sollte hier angewendet werden
@retry_on_failure
def _connect(self):
"""Stellt Verbindung zum Google Sheet her."""
self.sheet = None # Sicherstellen, dass sheet vor try None ist
debug_print("Verbinde mit Google Sheets...")
try:
scope = ["https://www.googleapis.com/auth/spreadsheets"]
creds = ServiceAccountCredentials.from_json_keyfile_name(CREDENTIALS_FILE, scope)
gc = gspread.authorize(creds)
sh = gc.open_by_url(Config.SHEET_URL)
self.sheet = sh.sheet1 # Greift auf das erste Blatt zu
debug_print("Verbindung zu Google Sheets erfolgreich.")
except gspread.exceptions.APIError as e:
# Logge spezifische API-Fehler von Google
debug_print(f"FEHLER bei Google API Verbindung: Status {e.response.status_code} - {e.response.text[:200]}")
raise e # Fehler weitergeben, damit retry greift
except Exception as e:
# Logge andere Verbindungsfehler
debug_print(f"FEHLER bei der Google Sheets Verbindung: {type(e).__name__} - {e}")
raise e # Fehler weitergeben
# retry_on_failure Decorator sollte hier angewendet werden
@retry_on_failure
def load_data(self):
"""Lädt alle Daten aus dem Sheet und aktualisiert self.sheet_values und self.headers."""
if not self.sheet:
debug_print("Fehler: Keine Sheet-Verbindung zum Laden der Daten.")
self.sheet_values = []
self.headers = []
return False # Signalisiert Fehler
debug_print("Lade Daten aus Google Sheet...")
try:
self.sheet_values = self.sheet.get_all_values() # Daten neu holen
if not self.sheet_values:
debug_print("Warnung: Google Sheet scheint leer zu sein oder keine Daten zurückgegeben.")
self.headers = []
return True # Kein Fehler beim Laden, aber keine Daten
if len(self.sheet_values) >= 1:
self.headers = self.sheet_values[0] # Speichere die erste Zeile als Header
else:
self.headers = [] # Sollte nicht passieren, wenn sheet_values nicht leer war
debug_print(f"Daten neu geladen: {len(self.sheet_values)} Zeilen insgesamt.")
return True # Signalisiert Erfolg
except gspread.exceptions.APIError as e:
debug_print(f"Google API Fehler beim Laden der Sheet Daten: Status {e.response.status_code} - {e.response.text[:200]}")
raise e # Damit retry greift
except Exception as e:
debug_print(f"Allgemeiner Fehler beim Laden der Google Sheet Daten: {e}")
raise e # Damit retry greift
def get_data(self):
"""Gibt die aktuell im Handler gespeicherten Daten zurück (ohne die ersten 5 Header-Zeilen)."""
header_rows = 5 # Definiert die Anzahl der zu überspringenden Header-Zeilen
if not self.sheet_values or len(self.sheet_values) <= header_rows:
if self.sheet_values: # Logge nur, wenn Daten da, aber zu wenige
debug_print(f"Warnung in get_data: Nur {len(self.sheet_values)} Zeilen vorhanden, weniger als {header_rows} Header-Zeilen erwartet.")
return []
# Gibt eine Slice der Liste zurück, die die Datenzeilen enthält
return self.sheet_values[header_rows:]
def get_all_data_with_headers(self):
"""Gibt alle aktuell im Handler gespeicherten Daten inklusive Header zurück."""
if not self.sheet_values:
debug_print("Warnung in get_all_data_with_headers: Keine Daten im Handler gespeichert.")
return []
return self.sheet_values
def _get_col_letter(self, col_idx_1_based):
""" Konvertiert 1-basierten Spaltenindex in Buchstaben (A, B, ..., Z, AA, ...). """
string = ""
n = col_idx_1_based
if n < 1: return None # Ungültiger Index
while n > 0:
n, remainder = divmod(n - 1, 26)
string = chr(65 + remainder) + string
return string
# Angepasst: Sucht nur noch nach EXAKT LEER ("")
def get_start_row_index(self, check_column_key, min_sheet_row=7):
"""
Findet den Index der ersten Zeile (0-basiert für Daten nach Header),
ab einer Mindestzeilennummer im Sheet, in der der Wert in der
Spalte (definiert durch check_column_key) EXAKT LEER ("") ist.
Lädt die Daten vor der Prüfung neu.
Args:
check_column_key (str): Der Schlüssel in COLUMN_MAP für die zu prüfende Spalte.
min_sheet_row (int): Die 1-basierte Zeilennummer im Sheet, ab der gesucht werden soll.
Returns:
int: Der 0-basierte Index in der Datenliste (ohne Header),
oder -1 bei Fehler (z.B. Schlüssel nicht gefunden),
oder der Index nach der letzten Datenzeile, wenn alle gefüllt sind.
"""
if not self.load_data(): return -1 # Fehlerindikator
header_rows = 5
data_rows = self.get_data()
if not data_rows: return 0
check_column_index = COLUMN_MAP.get(check_column_key)
if check_column_index is None:
debug_print(f"FEHLER: Schlüssel '{check_column_key}' nicht in COLUMN_MAP gefunden!")
return -1
actual_col_letter = self._get_col_letter(check_column_index + 1)
search_start_index_in_data = max(0, min_sheet_row - header_rows - 1)
debug_print(f"get_start_row_index: Suche ab Daten-Index {search_start_index_in_data} nach EXAKT LEEREM Wert (=='') in Spalte '{check_column_key}' ({actual_col_letter})...")
if search_start_index_in_data >= len(data_rows):
debug_print(f"Start-Suchindex ({search_start_index_in_data}) >= Datenlänge ({len(data_rows)}).")
return len(data_rows)
for i in range(search_start_index_in_data, len(data_rows)):
row = data_rows[i]
current_sheet_row = i + header_rows + 1
cell_value = ""; is_exactly_empty = True
if len(row) > check_column_index:
cell_value = row[check_column_index]
if cell_value != "": is_exactly_empty = False
log_debug = (i == search_start_index_in_data or i % 1000 == 0 or is_exactly_empty or i in range(10110, 10116)) # Angepasste Log-Punkte
if log_debug: debug_print(f" -> Prüfe Daten-Index {i} (Sheet {current_sheet_row}): Wert in {actual_col_letter}='{cell_value}' (Typ: {type(cell_value)}). Ist exakt leer ('')? {is_exactly_empty}")
if is_exactly_empty:
debug_print(f"Erste Zeile ab {min_sheet_row} mit EXAKT LEEREM Wert in Spalte {actual_col_letter} gefunden: Zeile {current_sheet_row} (Daten-Index {i})")
return i
last_index = len(data_rows)
debug_print(f"Alle Zeilen ab Daten-Index {search_start_index_in_data} haben einen nicht-leeren Wert in Spalte {actual_col_letter}. Nächster Daten-Index wäre {last_index}.")
return last_index
# --- ÜBERARBEITETE METHODE mit besserem Error Handling ---
@retry_on_failure
def batch_update_cells(self, update_data):
"""
Führt ein Batch-Update im Google Sheet durch. Beinhaltet robustere
Fehlerbehandlung und gibt nur True bei echtem Erfolg zurück.
Args:
update_data (list): Eine Liste von Dictionaries, jedes mit 'range' und 'values'.
z.B. [{'range': 'A1', 'values': [['Wert']]}, ...]
Returns:
bool: True bei Erfolg, False bei Fehler nach Retries.
"""
if not self.sheet:
debug_print("FEHLER: Keine Sheet-Verbindung für Batch-Update.")
return False
if not update_data:
# debug_print("Keine Daten für Batch-Update vorhanden.") # Weniger Lärm
return True # Nichts zu tun ist technisch ein Erfolg
success = False # Standard: Nicht erfolgreich
try:
debug_print(f" -> Versuche sheet.batch_update mit {len(update_data)} Operationen...")
self.sheet.batch_update(update_data, value_input_option='USER_ENTERED')
# Wenn keine Exception aufgetreten ist, war es erfolgreich
success = True
# Logge Erfolg nicht mehr hier, sondern in der aufrufenden Funktion
# debug_print(f" -> sheet.batch_update erfolgreich abgeschlossen.")
except gspread.exceptions.APIError as e:
# Spezifische Fehler loggen
debug_print(f" -> FEHLER (Google API Error) beim Batch-Update: Status {e.response.status_code}")
# Logge die ersten 500 Zeichen der Fehlermeldung von Google
try:
error_details = e.response.json() # Versuche JSON zu parsen
debug_print(f" -> Details: {str(error_details)[:500]}")
except: # Falls die Antwort kein JSON ist
debug_print(f" -> Raw Response Text: {e.response.text[:500]}")
# WICHTIG: Fehler weitergeben, damit retry_on_failure greifen kann
raise e
except Exception as e:
# Andere Fehler loggen
debug_print(f" -> FEHLER (Allgemein) beim Batch-Update: {type(e).__name__} - {e}")
import traceback
debug_print(traceback.format_exc()) # Gib den vollen Traceback aus
# Fehler weitergeben, damit retry_on_failure greifen kann
raise e # Oder return False, wenn Retries nicht helfen sollen? Besser weitergeben.
# Gib den Erfolgsstatus zurück
return success
# --- Ende GoogleSheetHandler Klasse ---
# ==================== WIKIPEDIA SCRAPER ====================
class WikipediaScraper:
"""
Handles searching Wikipedia articles and extracting relevant company data.
Version: 1.6.5 logic - Improved infobox parsing, disambiguation handling, and standard logging.
"""
def __init__(self, user_agent=None):
"""
Initialisiert den Scraper mit einer Requests-Session und Logger.
"""
self.logger = logging.getLogger(__name__)
self.logger.debug(f"Logger für WikipediaScraper ('{__name__}') initialisiert.")
self.user_agent = user_agent or getattr(Config, 'USER_AGENT', 'Mozilla/5.0 (compatible; Datenanreicherungsskript/1.0)')
self.session = requests.Session()
self.session.headers.update({'User-Agent': self.user_agent})
self.logger.debug(f"Requests Session mit User-Agent '{self.user_agent}' initialisiert.")
self.keywords_map = {
'branche': ['branche', 'wirtschaftszweig', 'industry', 'tätigkeit', 'sektor', 'produkte', 'leistungen'],
'umsatz': ['umsatz', 'erlös', 'revenue', 'jahresumsatz', 'konzernumsatz', 'ergebnis'],
'mitarbeiter': ['mitarbeiter', 'mitarbeiterzahl', 'beschäftigte', 'employees', 'number of employees', 'personal', 'belegschaft']
}
try:
wiki_lang = getattr(Config, 'LANG', 'de')
wikipedia.set_lang(wiki_lang)
wikipedia.set_rate_limiting(True)
self.logger.info(f"Wikipedia library language set to '{wiki_lang}'. Rate limiting enabled.")
except Exception as e:
self.logger.warning(f"Fehler beim Setzen der Wikipedia-Sprache oder Rate Limiting: {e}")
def _get_full_domain(self, website):
"""Extrahiert die normalisierte Domain (ohne www, ohne Pfad) aus einer URL."""
if not website or not isinstance(website, str): return ""
website_lower = website.lower().strip()
if not website_lower or website_lower == 'k.a.': return ""
website_lower = re.sub(r'^https?:\/\/', '', website_lower)
if '@' in website_lower: website_lower = website_lower.split('@', 1)[1]
if website_lower.startswith('www.'): website_lower = website_lower[4:]
domain = website_lower.split('/')[0].split(':')[0]
return domain
def _generate_search_terms(self, company_name, website):
"""Generiert eine Liste von Suchbegriffen für die Wikipedia-Suche."""
if not company_name: return []
terms = set()
full_domain = self._get_full_domain(website)
if full_domain: terms.add(full_domain)
normalized_name = normalize_company_name(company_name) # Annahme: existiert
if normalized_name:
name_parts = normalized_name.split()
if len(name_parts) > 0: terms.add(name_parts[0])
if len(name_parts) > 1: terms.add(" ".join(name_parts[:2]))
terms.add(normalized_name)
company_name_lower = company_name.lower()
if company_name_lower != normalized_name and company_name_lower not in terms:
terms.add(company_name_lower)
final_terms = [term for term in list(terms) if term][:5]
self.logger.debug(f"Generierte Suchbegriffe für '{company_name}': {final_terms}")
return final_terms
@retry_on_failure # Annahme: Decorator existiert
def _get_page_soup(self, url):
"""Holt HTML von einer URL und gibt ein BeautifulSoup-Objekt zurück."""
if not url or not isinstance(url, str) or not url.startswith("http"):
self.logger.warning(f"_get_page_soup: Ungültige URL '{url}'.")
return None
try:
self.logger.debug(f"_get_page_soup: Rufe URL ab: {url}")
response = self.session.get(url, timeout=20)
response.raise_for_status()
response.encoding = 'utf-8'
soup = BeautifulSoup(response.text, getattr(Config, 'HTML_PARSER', 'html.parser'))
self.logger.debug(f"_get_page_soup: Parsen von {url} erfolgreich.")
return soup
except requests.exceptions.Timeout:
self.logger.error(f"_get_page_soup: Timeout beim Abrufen von {url}")
raise
except requests.exceptions.RequestException as e:
self.logger.error(f"_get_page_soup: Netzwerkfehler beim Abrufen von HTML von {url}: {e}")
raise e
except Exception as e:
self.logger.error(f"_get_page_soup: Fehler beim Parsen von HTML von {url}: {e}")
raise e
def _validate_article(self, page, company_name, website):
"""
Validiert, ob ein Wikipedia-Artikel zum Unternehmen passt.
Prüft Titelähnlichkeit und ob die Firmenwebsite verlinkt ist.
"""
if not page or not company_name: return False
self.logger.debug(f"Validiere Artikel '{page.title}' für Firma '{company_name}' (Website: {website})...")
full_domain = self._get_full_domain(website)
normalized_company = normalize_company_name(company_name)
normalized_title = normalize_company_name(page.title)
# 1. Titelähnlichkeit
similarity = SequenceMatcher(None, normalized_title, normalized_company).ratio()
self.logger.debug(f" -> Titelähnlichkeit: {similarity:.2f} ('{normalized_title}' vs '{normalized_company}')")
# 2. Link-Prüfung
domain_found = False
if full_domain:
self.logger.debug(f" -> Suche nach Domain '{full_domain}' in Links von {page.url}...")
soup = self._get_page_soup(page.url)
if soup:
infobox = soup.select_one('table[class*="infobox"]')
if infobox:
website_links = infobox.find_all('a', href=True)
for link in website_links:
href = link.get('href', '')
if href.startswith('http') and full_domain in self._get_full_domain(href):
link_text = link.get_text(strip=True).lower()
th = link.find_previous('th')
th_text = th.get_text(strip=True).lower() if th else ""
if any(kw in link_text for kw in ['website', 'webseite', 'offizielle']) or \
any(kw in th_text for kw in ['website', 'webseite', 'webauftritt']):
self.logger.debug(f" -> Domain '{full_domain}' in Infobox-Link gefunden (TH: '{th_text}', Text: '{link_text}', URL: {href})")
domain_found = True
break
else:
self.logger.debug(f" -> Domain '{full_domain}' in Infobox-Link gefunden (URL: {href}, kein Keyword-Match im Text/TH)")
domain_found = True
break
if not domain_found:
self.logger.debug(" -> Domain nicht in Infobox-Links gefunden, suche in allen externen Links...")
all_links = soup.find_all('a', href=True, class_=re.compile(r'.*\bexternal\b.*'))
if not all_links: all_links = soup.find_all('a', href=True)
for link in all_links:
href = link.get('href', '')
if href.startswith('http') and full_domain in self._get_full_domain(href):
if not any(site in href for site in ['wikipedia.org', 'wikimedia.org', 'wikidata.org', 'archive.org', 'webcitation.org']):
self.logger.debug(f" -> Domain '{full_domain}' in externem Link gefunden (URL: {href})")
domain_found = True
break
else:
self.logger.warning(f" -> Konnte HTML für Link-Prüfung von {page.url} nicht laden.")
else:
self.logger.debug(" -> Keine Website-Domain für Link-Prüfung vorhanden.")
# 3. Entscheidung
threshold = getattr(Config, 'SIMILARITY_THRESHOLD', 0.65)
if domain_found:
threshold = max(0.35, threshold - 0.3)
self.logger.debug(f" -> Domain gefunden, Ähnlichkeitsschwelle angepasst auf {threshold:.2f}")
is_valid = similarity >= threshold
log_level = logging.INFO if is_valid else logging.DEBUG
self.logger.log(log_level, f" => Artikel '{page.title}' {'VALIDIERT' if is_valid else 'NICHT validiert'} (Ähnlichkeit={similarity:.2f}, Schwelle={threshold:.2f}, Domain gefunden? {domain_found})")
return is_valid
def extract_categories(self, soup):
"""Extrahiert Wikipedia-Kategorien aus dem Soup-Objekt."""
if not soup: return "k.A."
cats_filtered = []
try:
cat_div = soup.find('div', id="mw-normal-catlinks")
if cat_div:
ul = cat_div.find('ul')
if ul:
cats = [clean_text(li.get_text()) for li in ul.find_all('li')]
cats_filtered = [c for c in cats if c and "kategorien:" not in c.lower()]
self.logger.debug(f"Kategorien gefunden: {cats_filtered}")
else: self.logger.debug("Kein 'ul' in 'mw-normal-catlinks' gefunden.")
else: self.logger.debug("Kein 'div#mw-normal-catlinks' gefunden.")
except Exception as e:
self.logger.error(f"Fehler beim Extrahieren der Kategorien: {e}")
return ", ".join(cats_filtered) if cats_filtered else "k.A."
def _extract_first_paragraph_from_soup(self, soup):
"""Extrahiert den ersten aussagekräftigen Absatz aus dem Soup-Objekt."""
if not soup: return "k.A."
paragraph_text = "k.A."
try:
content_div = soup.find('div', class_='mw-parser-output')
search_area = content_div if content_div else soup
paragraphs = search_area.find_all('p', recursive=False)
if not paragraphs: paragraphs = search_area.find_all('p', recursive=True)
self.logger.debug(f"Suche ersten Absatz in {len(paragraphs)} gefundenen <p>-Tags...")
for idx, p in enumerate(paragraphs):
if not p.get_text(strip=True):
self.logger.debug(f" -> Überspringe leeres <p> Tag (Index {idx})")
continue
if p.find(['img', 'table', 'figure', 'div'], recursive=False):
self.logger.debug(f" -> Überspringe <p> Tag (Index {idx}), da er Blockelemente enthält.")
continue
for sup in p.find_all('sup', class_='reference'): sup.decompose()
for span in p.find_all('span', id='coordinates'): span.decompose()
text = clean_text(p.get_text(separator=' ', strip=True)) # Annahme: clean_text existiert
if text and len(text) > 40:
self.logger.debug(f" -> Ersten gültigen Absatz (Index {idx}) gefunden: {text[:100]}...")
paragraph_text = text[:1000]
break
else:
self.logger.debug(f" -> Überspringe <p> Tag (Index {idx}), Text zu kurz oder leer nach clean_text: '{text[:50]}...'")
except Exception as e:
self.logger.error(f"Fehler beim Extrahieren des ersten Absatzes: {e}")
if paragraph_text == "k.A.":
self.logger.warning("Kein passender erster Absatz gefunden.")
return paragraph_text
def _extract_infobox_value(self, soup, target):
"""
Extrahiert gezielt Branche, Umsatz oder Mitarbeiter aus der Infobox.
Berücksichtigt Header in <th> oder fett formatierten <td>.
"""
self.logger.debug(f"--- Entering _extract_infobox_value for target '{target}' ---")
if not soup or target not in self.keywords_map:
self.logger.debug(f"_extract_infobox_value: Ungültiger Input (Soup: {soup is not None}, Target: {target})")
return "k.A."
keywords = self.keywords_map[target]
self.logger.debug(f"_extract_infobox_value: Suche nach '{target}' mit Keywords: {keywords}")
infobox = soup.select_one('table[class*="infobox"]')
if not infobox:
self.logger.debug(" -> KEINE Infobox via select_one 'table[class*=\"infobox\"]' gefunden.")
return "k.A."
self.logger.debug(f" -> Infobox gefunden (via select_one 'table[class*=\"infobox\"]')")
value_found = "k.A."
try:
rows = infobox.find_all('tr')
self.logger.debug(f" -> Analysiere {len(rows)} Zeilen in der Infobox.")
for idx, row in enumerate(rows):
self.logger.debug(f" --- Prüfe Roh-HTML Zeile {idx}: {str(row)[:150]}...")
cells = row.find_all(['th', 'td'], recursive=False)
header_text = None
value_cell = None
# Strukturprüfung
if len(cells) == 2 and cells[0].name == 'th' and cells[1].name == 'td':
header_text = cells[0].get_text(strip=True)
value_cell = cells[1]
self.logger.debug(f" -> Zeile {idx}: Struktur TH + TD erkannt.")
elif len(cells) == 2 and cells[0].name == 'td' and cells[1].name == 'td':
first_cell_is_header_like = False
style = cells[0].get('style', '').lower()
if 'font-weight' in style and ('bold' in style or '700' in style or '800' in style or '900' in style):
first_cell_is_header_like = True
elif cells[0].find(['b', 'strong'], recursive=False):
first_cell_is_header_like = True
if first_cell_is_header_like:
header_text = cells[0].get_text(strip=True)
value_cell = cells[1]
self.logger.debug(f" -> Zeile {idx}: Struktur TD(Header-like) + TD erkannt.")
else:
self.logger.debug(f" -> Zeile {idx}: Struktur TD + TD, aber erstes TD nicht als Header erkannt.")
else:
self.logger.debug(f" -> Zeile {idx}: Übersprungen (Struktur passt nicht, Zellen: {len(cells)}, Typen: {[c.name for c in cells]})")
# Verarbeitung, wenn Struktur passt
if header_text is not None and value_cell is not None:
self.logger.debug(f" -> Verarbeite Zeile {idx} mit Header='{header_text}'")
header_text_lower = header_text.lower()
matched_keyword = None
for kw in keywords:
if kw in header_text_lower:
matched_keyword = kw
break
if matched_keyword:
self.logger.debug(f" --> Keyword '{matched_keyword}' gefunden in Header '{header_text}'!")
for sup in value_cell.find_all(['sup', 'span']):
if (sup.name == 'sup' and sup.has_attr('class') and 'reference' in sup['class']) or \
(sup.name == 'span' and sup.get('style') and 'display:none' in sup['style']):
self.logger.debug(f" -> Entferne störendes Element: {sup.get_text(strip=True)}")
sup.decompose()
raw_value_text = value_cell.get_text(separator=' ', strip=True)
self.logger.debug(f" -> Roher TD/Value-Text nach Decompose: '{raw_value_text}'")
cleaned_raw_value = clean_text(raw_value_text) # Annahme: existiert
if target == 'branche':
clean_val = re.sub(r'\s*\([^)]*\)', '', cleaned_raw_value).strip()
clean_val = clean_val.split('\n')[0].strip()
value_found = clean_val if clean_val else "k.A."
self.logger.info(f" --> Branche extrahiert: '{value_found}'") # Logge Fund als INFO
elif target == 'umsatz':
numeric_val = extract_numeric_value(cleaned_raw_value, is_umsatz=True) # Annahme: existiert
value_found = numeric_val
self.logger.info(f" --> Umsatz extrahiert (aus '{cleaned_raw_value}'): '{value_found}'")
elif target == 'mitarbeiter':
numeric_val = extract_numeric_value(cleaned_raw_value, is_umsatz=False) # Annahme: existiert
value_found = numeric_val
self.logger.info(f" --> Mitarbeiter extrahiert (aus '{cleaned_raw_value}'): '{value_found}'")
break # Ersten Treffer nehmen
# Ende der Zeilenschleife
if value_found != "k.A.":
self.logger.debug(f" -> Finaler Wert für '{target}' gefunden: '{value_found}'")
else:
self.logger.debug(f" -> Kein passender Eintrag für '{target}' in der gesamten Infobox gefunden.")
except Exception as e:
self.logger.exception(f"Fehler beim Durchlaufen der Infobox-Zeilen für '{target}': {e}")
return "k.A."
return value_found
def extract_company_data(self, page_url):
"""
Extrahiert Firmendaten von einer Wikipedia-URL.
"""
default_result = {'url': page_url if page_url else 'k.A.', 'first_paragraph': 'k.A.', 'branche': 'k.A.', 'umsatz': 'k.A.', 'mitarbeiter': 'k.A.', 'categories': 'k.A.'}
if not page_url or not isinstance(page_url, str) or "wikipedia.org" not in page_url.lower():
self.logger.warning(f"extract_company_data: Ungültige URL '{page_url}'.")
return default_result
self.logger.info(f"Extrahiere Daten für Wiki-URL: {page_url}")
soup = self._get_page_soup(page_url)
if not soup:
self.logger.error(f" -> Fehler: Konnte Seite {page_url} nicht laden oder parsen.")
default_result['url'] = page_url
return default_result
# Extrahiere Daten aus dem Soup-Objekt
self.logger.debug(" -> Extrahiere ersten Absatz...")
first_paragraph = self._extract_first_paragraph_from_soup(soup)
self.logger.debug(" -> Extrahiere Kategorien...")
categories_val = self.extract_categories(soup)
self.logger.debug(" -> Extrahiere Branche aus Infobox...")
branche_val = self._extract_infobox_value(soup, 'branche')
self.logger.debug(" -> Extrahiere Umsatz aus Infobox...")
umsatz_val = self._extract_infobox_value(soup, 'umsatz')
self.logger.debug(" -> Extrahiere Mitarbeiter aus Infobox...")
mitarbeiter_val = self._extract_infobox_value(soup, 'mitarbeiter')
result = {
'url': page_url,
'first_paragraph': first_paragraph,
'branche': branche_val,
'umsatz': umsatz_val,
'mitarbeiter': mitarbeiter_val,
'categories': categories_val
}
self.logger.info(f" -> Extrahierte Daten: P={first_paragraph[:30]}..., B='{branche_val}', U='{umsatz_val}', M='{mitarbeiter_val}', C={categories_val[:30]}...")
return result
@retry_on_failure # Annahme: Decorator existiert
def search_company_article(self, company_name, website=None):
"""
Sucht einen passenden Wikipedia-Artikel und gibt das page-Objekt zurück.
Behandelt jetzt explizit Begriffsklärungsseiten.
"""
if not company_name:
self.logger.warning("Wikipedia search skipped: No company name provided.")
return None
search_terms = self._generate_search_terms(company_name, website)
if not search_terms:
self.logger.warning(f"Keine Suchbegriffe für '{company_name}' generiert.")
return None
self.logger.info(f"Starte Wikipedia-Suche für '{company_name}' (Website: {website}) mit Begriffen: {search_terms}")
processed_titles = set() # Verhindert doppelte Prüfung
# --- Interne Hilfsfunktion zum Prüfen einer Seite ---
def check_page(title_to_check):
if title_to_check in processed_titles:
self.logger.debug(f" -> Titel '{title_to_check}' bereits geprüft, überspringe.")
return None
processed_titles.add(title_to_check)
try:
self.logger.debug(f" -> Prüfe potenziellen Artikel: '{title_to_check}'")
page = wikipedia.page(title_to_check, auto_suggest=False, preload=True)
if self._validate_article(page, company_name, website):
return page # Erfolg wird von _validate_article geloggt
else:
self.logger.debug(f" -> Titel '{title_to_check}' nicht validiert.")
return None
except wikipedia.exceptions.PageError:
self.logger.debug(f" -> Seite '{title_to_check}' nicht gefunden (PageError).")
return None
except wikipedia.exceptions.DisambiguationError as e_inner:
self.logger.info(f" -> Begriffsklärung '{title_to_check}' gefunden. Prüfe Optionen...")
self.logger.debug(f" Optionen: {e_inner.options}")
best_option_page = None
for option in e_inner.options:
option_lower = option.lower()
is_company_candidate = False
if "(unternehmen)" in option_lower:
is_company_candidate = True
self.logger.debug(f" -> Option mit '(Unternehmen)' gefunden: '{option}'")
elif any(form in option_lower for form in [' gmbh', ' ag', ' kg', ' ltd', ' inc', ' corp', ' s.a.', ' se']):
is_company_candidate = True
self.logger.debug(f" -> Option mit Firmen-Keyword gefunden: '{option}'")
# --- Hinzugefügt: Prüfe Ähnlichkeit zum Firmennamen als Indikator ---
elif SequenceMatcher(None, normalize_company_name(option), normalize_company_name(company_name)).ratio() > 0.7:
is_company_candidate = True
self.logger.debug(f" -> Option mit hoher Namensähnlichkeit gefunden: '{option}'")
if is_company_candidate:
validated_option_page = check_page(option) # Rekursiver Check
if validated_option_page:
self.logger.info(f" -> Option '{option}' erfolgreich validiert!")
if best_option_page is None: # Nimm die erste validierte Unternehmensoption
best_option_page = validated_option_page
# Optional: Weitere Logik zur Auswahl der "besten" Option, falls mehrere passen
# break # Oder direkt die erste passende nehmen
if best_option_page:
return best_option_page
else:
self.logger.warning(f" -> Keine passende/validierte Unternehmens-Option in Begriffsklärung '{title_to_check}' gefunden.")
return None
except requests.exceptions.RequestException as e_req:
self.logger.warning(f" -> Netzwerkfehler beim Laden/Validieren von '{title_to_check}': {e_req}. Überspringe Titel.")
time.sleep(1)
return None
except Exception as e_page:
self.logger.error(f" -> Fehler bei Verarbeitung von Titel '{title_to_check}': {type(e_page).__name__} - {e_page}")
return None # Fehler bei dieser Seite
# --- Haupt-Suchlogik ---
self.logger.debug(f" -> Versuche direkten Match für '{company_name}'...")
validated_page = check_page(company_name)
if validated_page: return validated_page
self.logger.debug(f" -> Kein direkter Treffer/validiert. Starte Suche mit generierten Begriffen: {search_terms}")
for term in search_terms:
try:
self.logger.debug(f" -> Suche mit Begriff: '{term}'...")
search_results = wikipedia.search(term, results=getattr(Config, 'WIKIPEDIA_SEARCH_RESULTS', 5))
self.logger.debug(f" -> Suchergebnisse für '{term}': {search_results}")
if not search_results: continue
for title in search_results:
validated_page = check_page(title)
if validated_page: return validated_page
time.sleep(0.1)
except requests.exceptions.RequestException as e_search_req:
self.logger.error(f"Netzwerkfehler während Wikipedia-Suche für '{term}': {e_search_req}")
time.sleep(2)
raise e_search_req
except Exception as e_search:
self.logger.error(f"Allgemeiner Fehler während Wikipedia-Suche für '{term}': {e_search}")
continue
self.logger.warning(f"Kein passender & validierter Wikipedia-Artikel für '{company_name}' gefunden nach Prüfung aller Begriffe und Optionen.")
return None
# ==================== WEBSITE SCRAPING ====================
@retry_on_failure
def get_website_raw(url, max_length=1000, verify_cert=False):
"""Holt Textinhalt von einer Website, versucht Cookie-Banner zu umgehen."""
if not url or not isinstance(url, str) or url.strip().lower() == 'k.a.':
return "k.A."
if not url.lower().startswith("http"):
url = "https://" + url
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36"
}
try:
response = requests.get(url, timeout=15, headers=headers, verify=verify_cert) # Etwas längerer Timeout
response.raise_for_status()
response.encoding = response.apparent_encoding
soup = BeautifulSoup(response.text, Config.HTML_PARSER)
# --- Versuch 1: Hauptinhalt-Tags finden ---
content_area = (
soup.find('main') or # Suche <main>
soup.find('article') or # Oder <article>
soup.find(id='content') or # Oder <div id="content">
soup.find(id='main-content') or # Oder <div id="main-content">
soup.find(class_='main-content') or # Oder <div class="main-content">
soup.find(class_='content') # Oder <div class="content">
# Füge ggf. weitere spezifische Selektoren hinzu, die du oft siehst
)
if content_area:
debug_print(f"Gezielten Inhaltsbereich gefunden ({content_area.name}#{content_area.get('id')} oder .{content_area.get('class')}) für {url}")
else:
# --- Fallback: Body nehmen, ABER Banner versuchen zu entfernen ---
debug_print(f"Kein spezifischer Inhaltsbereich gefunden für {url}. Nutze Body und versuche Banner zu entfernen.")
content_area = soup.find('body')
if content_area:
# Versuche, häufige Cookie-Banner Strukturen zu entfernen
banner_selectors = [
'[id*="cookie"]', # IDs die "cookie" enthalten
'[class*="cookie"]', # Klassen die "cookie" enthalten
'[id*="consent"]', # IDs die "consent" enthalten
'[class*="consent"]', # Klassen die "consent" enthalten
'[id*="banner"]', # IDs die "banner" enthalten (vorsichtig!)
'[class*="banner"]', # Klassen die "banner" enthalten (vorsichtig!)
'[role="dialog"]' # Oft für Popups/Banner genutzt (vorsichtig!)
]
banners_removed_count = 0
for selector in banner_selectors:
try:
potential_banners = content_area.select(selector)
for banner in potential_banners:
# Zusätzliche Prüfung: Enthält das Element typischen Banner-Text?
banner_text = banner.get_text(" ", strip=True).lower()
keywords = ["cookie", "zustimm", "ablehnen", "einverstanden", "datenschutz", "privacy", "akzeptier"]
if any(keyword in banner_text for keyword in keywords):
debug_print(f"Entferne potenzielles Banner ({selector}) mit Text: {banner_text[:100]}...")
banner.decompose() # Entferne das Element aus dem Baum
banners_removed_count += 1
except Exception as e_select:
debug_print(f"Fehler beim Versuch Banner mit Selektor '{selector}' zu entfernen: {e_select}")
if banners_removed_count > 0:
debug_print(f"{banners_removed_count} potenzielle Banner-Elemente entfernt.")
# --- Text extrahieren aus gefundenem Bereich (oder Body) ---
if content_area:
for script_or_style in content_area(["script", "style"]): # Skripte/Styles entfernen
script_or_style.decompose()
text = content_area.get_text(separator=' ', strip=True)
text = re.sub(r'\s+', ' ', text) # Normalisiere Whitespace
# --- Zusätzliche Prüfung: Ist der extrahierte Text *nur* Banner-Text? ---
banner_keywords_strict = ["cookie", "zustimmen", "ablehnen", "einverstanden", "datenschutz", "privacy", "akzeptier", "einstellung", "partner", "analyse", "marketing"]
text_lower = text.lower()
keyword_hits = sum(1 for keyword in banner_keywords_strict if keyword in text_lower)
# Heuristik: Wenn der Text kurz ist UND viele Banner-Keywords enthält -> Verwerfen
if len(text) < 500 and keyword_hits >= 3:
debug_print(f"WARNUNG: Extrahierter Text für {url} scheint nur Cookie-Banner zu sein (Länge {len(text)}, {keyword_hits} Keywords). Verwerfe Text.")
return "k.A. (Nur Cookie-Banner erkannt)"
result = text[:max_length]
debug_print(f"Website {url} erfolgreich gescrapt. Extrahierter Text (Länge {len(result)}): {result[:100]}...")
return result
else:
debug_print(f"Kein <body> oder spezifischer Inhaltsbereich gefunden in {url}")
return "k.A."
except requests.exceptions.SSLError as e:
debug_print(f"SSL-Fehler beim Abrufen der Website {url}: {e}. Versuche ohne Zertifikatsprüfung...")
if verify_cert:
return get_website_raw(url, max_length, verify_cert=False)
else:
return "k.A."
except requests.exceptions.RequestException as e:
debug_print(f"Netzwerk-/HTTP-Fehler beim Abrufen der Website {url}: {e}")
return "k.A."
except Exception as e:
debug_print(f"Allgemeiner Fehler beim Scraping von {url}: {e}")
return "k.A."
# Die Hilfsfunktion summarize_batch_openai wird weiterhin benötigt
# (Code dafür bleibt wie in der Antwort von 16:24 Uhr)
@retry_on_failure
def summarize_batch_openai(tasks_data):
"""
Fasst eine Liste von Rohtexten in einem einzigen OpenAI API Call zusammen.
Die Prüfung auf das Token-Limit wird jetzt primär der API überlassen.
Args:
tasks_data (list): Eine Liste von Dictionaries, jedes enthält:
{'row_num': int, 'raw_text': str}
Returns:
dict: Ein Dictionary, das Zeilennummern auf ihre Zusammenfassungen mappt.
z.B. {2122: "Zusammenfassung A", 2123: "Zusammenfassung B"}
Bei Fehlern oder fehlenden Zusammenfassungen wird "k.A." verwendet.
"""
if not tasks_data: return {}
# Filtere Tasks, die gültigen Text haben
valid_tasks = [t for t in tasks_data if t.get("raw_text") and t["raw_text"] not in ["k.A.", "k.A. (Nur Cookie-Banner erkannt)", "k.A. (Fehler)"] and str(t.get("raw_text")).strip()]
if not valid_tasks:
debug_print("Keine gültigen Rohtexte für Batch-Zusammenfassung gefunden.")
return {t['row_num']: "k.A. (Kein gültiger Rohtext)" for t in tasks_data}
debug_print(f"Starte Batch-Zusammenfassung für {len(valid_tasks)} gültige Texte (Zeilen: {[t['row_num'] for t in valid_tasks]})...")
# --- Aggregierten Prompt erstellen ---
prompt_parts = [
"Du bist ein KI-Assistent...", # (Rest des Prompts wie gehabt)
"RESULTAT <Zeilennummer>: <Zusammenfassung für diese Zeilennummer>",
"\n--- Texte zur Zusammenfassung ---"
]
text_block = ""
row_numbers_in_batch = [] # Zeilen, die tatsächlich im Prompt landen
# Baue den Textblock ohne interne Längenprüfung zusammen
for task in valid_tasks:
row_num = task['row_num']
raw_text = task['raw_text']
# Kürzen sollte in get_website_raw passieren, aber zur Sicherheit:
raw_text = raw_text[:1500] # Limitiere jeden Text auf max 1500 Zeichen im Prompt
entry_text = f"\n--- TEXT Zeile {row_num} ---\n{raw_text}\n--- ENDE TEXT Zeile {row_num} ---\n"
text_block += entry_text
row_numbers_in_batch.append(row_num) # Füge die Zeilennummer hinzu
# --- Interne Längenprüfung ENTFERNT ---
# max_chars_per_batch = 15000 # Nicht mehr relevant für die Logik hier
# if total_chars + len(entry_text) > max_chars_per_batch: # ENTFERNT
# debug_print(f"WARNUNG: ...") # ENTFERNT
# continue # ENTFERNT
if not row_numbers_in_batch:
# Sollte nur passieren, wenn valid_tasks leer war
debug_print("Keine Texte im Batch für OpenAI.")
return {t['row_num']: "k.A. (Validierungsfehler?)" for t in tasks_data}
prompt_parts.append(text_block)
prompt_parts.append("--- Ende der Texte ---")
prompt_parts.append("Bitte gib NUR die 'RESULTAT <Zeilennummer>: ...' Zeilen zurück.")
final_prompt = "\n".join(prompt_parts)
# Optional: Token zählen zur Info, aber nicht zur Blockade
try:
prompt_tokens = token_count(final_prompt)
debug_print(f"Geschätzte Prompt-Tokens für Batch: {prompt_tokens} (Limit ca. 4096 für gpt-3.5-turbo)")
if prompt_tokens > 3500: # Nur eine Warnung
debug_print("WARNUNG: Geschätzte Prompt-Tokens hoch, API könnte Fehler werfen.")
except Exception as e_tc:
debug_print(f"Fehler beim Token-Zählen: {e_tc}")
# --- OpenAI API Call (Die API wirft Fehler bei Token-Limit) ---
chat_response = call_openai_chat(final_prompt, temperature=0.2)
# --- Antwort parsen (wie gehabt) ---
summaries = {row_num: "k.A. (Keine Antwort geparst)" for row_num in row_numbers_in_batch}
if chat_response:
# ... (Parsing-Logik bleibt gleich) ...
lines = chat_response.strip().split('\n'); parsed_count = 0
for line in lines:
match = re.match(r"RESULTAT (\d+): (.*)", line.strip())
if match:
row_num = int(match.group(1)); summary_text = match.group(2).strip()
if row_num in summaries: summaries[row_num] = summary_text; parsed_count += 1
debug_print(f"Batch-Zusammenfassung: {parsed_count} von {len(row_numbers_in_batch)} erfolgreich geparst.")
if parsed_count < len(row_numbers_in_batch): debug_print(f"WARNUNG: Nicht alle Zusammenfassungen geparst. Antwort: {chat_response[:500]}...")
else:
debug_print("Fehler: Keine gültige Antwort von OpenAI für Batch-Zusammenfassung erhalten.")
# Wenn der API Call fehlschlägt (z.B. Token Limit), ist chat_response None,
# alle summaries bleiben "k.A."
# Füge k.A. für Tasks hinzu, die ungültigen Rohtext hatten (aus valid_tasks gefiltert)
for task in tasks_data:
if task['row_num'] not in summaries:
summaries[task['row_num']] = "k.A. (Ungültiger Rohtext o.ä.)"
return summaries
# ==================== OPENAI / CHATGPT FUNCTIONS ====================
@retry_on_failure
def call_openai_chat(prompt, temperature=0.3, model=None):
"""Zentrale Funktion für OpenAI Chat API Aufrufe."""
if not Config.API_KEYS.get('openai'):
debug_print("Fehler: OpenAI API Key nicht konfiguriert.")
return None
if not prompt:
debug_print("Fehler: Leerer Prompt für OpenAI.")
return None
current_model = model if model else Config.TOKEN_MODEL
try:
# Token zählen vor dem Senden (optional, aber gut für Debugging)
# prompt_tokens = token_count(prompt)
# debug_print(f"Sende Prompt an OpenAI ({current_model}, {prompt_tokens} Tokens)...")
response = openai.ChatCompletion.create(
model=current_model,
messages=[{"role": "user", "content": prompt}],
temperature=temperature
)
result = response.choices[0].message.content.strip()
# Token zählen für die Antwort
# completion_tokens = token_count(result)
# total_tokens = response.usage.total_tokens
# debug_print(f"OpenAI Antwort erhalten ({completion_tokens} Completion Tokens, {total_tokens} Gesamt).")
return result
except openai.error.InvalidRequestError as e:
debug_print(f"OpenAI Invalid Request Error: {e}")
# Hier könnte man prüfen, ob es am Token Limit liegt
if "maximum context length" in str(e):
debug_print("Fehler scheint Token Limit zu sein. Prompt evtl. zu lang.")
# TODO: Strategie für zu lange Prompts (kürzen, splitten?)
return None
except openai.error.OpenAIError as e: # Fängt RateLimitError, APIError etc. ab
debug_print(f"OpenAI API Fehler: {e}")
raise # Fehler weitergeben, damit retry_on_failure greifen kann
except Exception as e:
debug_print(f"Allgemeiner Fehler bei OpenAI-Aufruf: {e}")
raise # Fehler weitergeben
def summarize_website_content(raw_text):
"""Erstellt Zusammenfassung von Website-Rohtext via OpenAI."""
if not raw_text or raw_text == "k.A." or raw_text.strip() == "":
return "k.A."
# Kürze den Rohtext, falls er sehr lang ist, um Token zu sparen/Limits zu vermeiden
max_raw_length = 3000 # Zeichenlimit für den Input der Zusammenfassung
if len(raw_text) > max_raw_length:
debug_print(f"Kürze Rohtext für Zusammenfassung von {len(raw_text)} auf {max_raw_length} Zeichen.")
raw_text = raw_text[:max_raw_length]
prompt = (
"Du bist ein KI-Assistent, der Webinhalte analysiert.\n"
"Fasse den folgenden Text einer Unternehmenswebsite prägnant zusammen. "
"Konzentriere dich auf:\n"
"- Haupttätigkeitsfeld des Unternehmens\n"
"- Wichtigste Produkte und/oder Dienstleistungen\n"
"- Zielgruppe (falls erkennbar)\n\n"
f"Website-Text:\n```\n{raw_text}\n```\n\n"
"Zusammenfassung (max. 100 Wörter):"
)
summary = call_openai_chat(prompt, temperature=0.2)
return summary if summary else "k.A."
def evaluate_branche_chatgpt(crm_branche, beschreibung, wiki_branche, wiki_kategorien, website_summary):
"""
Ordnet das Unternehmen basierend auf den angegebenen Informationen exakt einer Branche
aus dem Ziel-Branchenschema (nur Kurzformen) zu. Validiert den ChatGPT-Vorschlag
strikt gegen die erlaubten Kurzformen und führt einen Fallback auf die (extrahierte)
CRM-Kurzform durch, falls der Vorschlag ungültig ist.
Args:
crm_branche (str): Branche laut CRM (kann noch Präfix enthalten).
beschreibung (str): Unternehmensbeschreibung (CRM).
wiki_branche (str): Branche aus Wikipedia (falls vorhanden).
wiki_kategorien (str): Wikipedia-Kategorien.
website_summary (str): Zusammenfassung des Website-Inhalts.
Returns:
dict: Enthält "branch" (die finale, gültige Kurzform oder Fehler),
"consistency" ('ok', 'X', 'fallback_crm_valid', 'fallback_invalid') und
"justification" (Begründung von ChatGPT oder Fallback-Info).
"""
# Globale Variablen für Schema und erlaubte Branches verwenden
global ALLOWED_TARGET_BRANCHES, TARGET_SCHEMA_STRING
# Grundlegende Prüfung: Ist das Schema überhaupt geladen?
if not ALLOWED_TARGET_BRANCHES:
debug_print("FEHLER in evaluate_branche_chatgpt: Ziel-Branchenschema (ALLOWED_TARGET_BRANCHES) ist leer. Abbruch.")
# Gib den CRM-Wert zurück, aber markiere als Fehler
return {"branch": crm_branche, "consistency": "error_schema_missing", "justification": "Fehler: Ziel-Schema nicht geladen"}
# Erstelle ein Set/Dict der erlaubten Branches in Kleinbuchstaben für effizientes Nachschlagen
# Speichert die Originalschreibweise als Wert.
allowed_branches_lookup = {b.lower(): b for b in ALLOWED_TARGET_BRANCHES}
# --- Prompt für ChatGPT erstellen ---
# Beginne mit den Regeln und der Liste der gültigen Kurzformen
prompt_parts = [TARGET_SCHEMA_STRING] # TARGET_SCHEMA_STRING sollte bereits die klare Anweisung enthalten
prompt_parts.append("\nOrdne das Unternehmen anhand folgender Angaben exakt einer Branche des Ziel-Branchenschemas (Kurzformen) zu:")
# Füge nur vorhandene Informationen hinzu und kürze sie ggf.
if crm_branche and crm_branche != "k.A.": prompt_parts.append(f"- CRM-Branche (Referenz): {crm_branche}")
if beschreibung and beschreibung != "k.A.": prompt_parts.append(f"- Beschreibung: {beschreibung[:500]}") # Kürzen
if wiki_branche and wiki_branche != "k.A.": prompt_parts.append(f"- Wikipedia-Branche: {wiki_branche}")
if wiki_kategorien and wiki_kategorien != "k.A.": prompt_parts.append(f"- Wikipedia-Kategorien: {wiki_kategorien[:500]}") # Kürzen
if website_summary and website_summary != "k.A.": prompt_parts.append(f"- Website-Zusammenfassung: {website_summary[:500]}") # Kürzen
# Fallback, wenn gar keine spezifischen Infos da sind
if len(prompt_parts) <= 2:
debug_print("Warnung in evaluate_branche_chatgpt: Zu wenige Informationen für Branchenevaluierung.")
return {"branch": crm_branche, "consistency": "error_no_info", "justification": "Fehler: Zu wenige Informationen für eine Einschätzung"}
# Füge die strengen Anweisungen für das Antwortformat hinzu
prompt_parts.append("\nWICHTIG: Antworte NUR mit dem exakten Kurznamen einer Branche aus der obigen Liste. Verwende KEINE Präfixe wie 'Hersteller / Produzenten >' oder 'Service provider (Dienstleister) >'.")
prompt_parts.append("\nAntworte ausschließlich im folgenden Format (keine Einleitung, kein Schlusssatz):")
prompt_parts.append("Branche: <Exakter Kurzname der Branche aus der Liste>")
prompt_parts.append("Übereinstimmung: <ok oder X (Vergleich deines Vorschlags mit der extrahierten Kurzform der CRM-Referenz)>")
prompt_parts.append("Begründung: <Sehr kurze Begründung für deinen Branchenvorschlag>")
prompt = "\n".join(prompt_parts)
# --- ChatGPT aufrufen ---
chat_response = call_openai_chat(prompt, temperature=0.0) # Niedrige Temperatur für konsistente Zuordnung
if not chat_response:
debug_print("Fehler in evaluate_branche_chatgpt: Keine Antwort von OpenAI erhalten.")
return {"branch": crm_branche, "consistency": "error_api_no_response", "justification": "Fehler: Keine Antwort von API"}
# --- Antwort parsen ---
lines = chat_response.strip().split("\n")
result = {"branch": None, "consistency": None, "justification": ""} # Initialisiere mit None
suggested_branch = ""
for line in lines:
line_lower = line.lower()
if line_lower.startswith("branche:"):
suggested_branch = line.split(":", 1)[1].strip()
# Entferne mögliche Anführungszeichen
suggested_branch = suggested_branch.strip('"\'')
elif line_lower.startswith("übereinstimmung:"):
# Wir überschreiben die Konsistenz später basierend auf unserer Logik
pass
elif line_lower.startswith("begründung:"):
result["justification"] = line.split(":", 1)[1].strip()
if not suggested_branch:
debug_print(f"Fehler in evaluate_branche_chatgpt: Konnte 'Branche:' nicht aus Antwort parsen: {chat_response}")
# Optional: Versuche Begründung als Branche zu nehmen? Eher nicht.
return {"branch": crm_branche, "consistency": "error_parsing", "justification": f"Fehler: Parsing der API Antwort fehlgeschlagen. Antwort: {chat_response}"}
# --- Validierung des ChatGPT-Vorschlags ---
final_branch = None
suggested_branch_lower = suggested_branch.lower()
if suggested_branch_lower in allowed_branches_lookup:
final_branch = allowed_branches_lookup[suggested_branch_lower] # Nimm korrekte Schreibweise
debug_print(f"ChatGPT-Branchenvorschlag '{suggested_branch}' ist gültig ('{final_branch}').")
# Konsistenz wird später gesetzt
result["consistency"] = "pending_comparison" # Temporärer Status
else:
# --- Fallback-Logik ---
debug_print(f"ChatGPT-Branchenvorschlag '{suggested_branch}' ist NICHT im Ziel-Schema ({len(ALLOWED_TARGET_BRANCHES)} Einträge) enthalten. Starte Fallback...")
# Versuche Kurzform aus CRM-Branche zu extrahieren
crm_short_branch = "k.A."
if crm_branche and ">" in crm_branche:
crm_short_branch = crm_branche.split(">", 1)[1].strip()
elif crm_branche and crm_branche != "k.A.": # Wenn CRM schon Kurzform sein könnte
crm_short_branch = crm_branche.strip()
# Prüfe, ob die extrahierte CRM-Kurzform gültig ist
if crm_short_branch != "k.A." and crm_short_branch.lower() in allowed_branches_lookup:
final_branch = allowed_branches_lookup[crm_short_branch.lower()] # Nimm korrekte Schreibweise
result["consistency"] = "fallback_crm_valid" # Setze Fallback-Status
# Kombiniere ChatGPT Begründung (falls vorhanden) mit Fallback-Info
fallback_reason = f"Fallback: Ungültiger ChatGPT-Vorschlag ('{suggested_branch}'). Gültige CRM-Kurzform '{final_branch}' verwendet."
result["justification"] = f"{fallback_reason} (ChatGPT Begründung war: {result.get('justification', 'Keine')})"
debug_print(f"Fallback auf gültige CRM-Kurzform erfolgreich: '{final_branch}'")
else:
# Wenn auch CRM-Kurzform ungültig oder nicht extrahierbar
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')})"
debug_print(f"Fallback fehlgeschlagen. Ungültiger Vorschlag: '{final_branch}', Ungültige CRM-Kurzform: '{crm_short_branch}'")
# Alternativ: Gib einen speziellen Fehlerwert zurück
# final_branch = "FEHLER - UNGÜLTIGE ZUWEISUNG"
# Setze den finalen Branch im Ergebnis-Dictionary
result["branch"] = final_branch if final_branch else "FEHLER"
# --- Konsistenzprüfung (Finale Bewertung) ---
# Extrahiere CRM-Kurzform für den Vergleich (erneut oder Variable von oben)
crm_short_to_compare = "k.A."
if crm_branche and ">" in crm_branche:
crm_short_to_compare = crm_branche.split(">", 1)[1].strip()
elif crm_branche and crm_branche != "k.A.":
crm_short_to_compare = crm_branche.strip()
# Vergleiche finalen Branch (falls nicht FEHLER) mit CRM-Kurzform (case-insensitive)
if result["branch"] != "FEHLER" and result["branch"].lower() == crm_short_to_compare.lower():
# Wenn sie übereinstimmen UND *kein* Fallback stattgefunden hat, ist es 'ok'.
if result["consistency"] == "pending_comparison":
result["consistency"] = "ok"
# Wenn Fallback auf gültige CRM stattfand (Status 'fallback_crm_valid'), bleibt dieser Status.
elif result["consistency"] == "pending_comparison":
# Wenn sie nicht übereinstimmen und kein Fallback stattfand, ist es 'X'.
result["consistency"] = "X"
# Wenn der Status bereits 'fallback_crm_valid' oder 'fallback_invalid' ist, bleibt er unverändert.
elif result["consistency"] is None: # Sollte nicht passieren, aber zur Sicherheit
result["consistency"] = "error_unknown_state"
# Entferne den temporären Status, falls er noch da ist
if result["consistency"] == "pending_comparison":
result["consistency"] = "error_comparison_failed"
# Debug-Ausgabe des finalen Ergebnisses vor Rückgabe
debug_print(f"Finale Branch-Evaluation: {result}")
return result
# TODO: Weitere ChatGPT-Funktionen (evaluate_fsm_suitability, etc.) analog überarbeiten:
# - Prompts verbessern (klarere Anweisungen, Kontext nur bei Bedarf)
# - call_openai_chat verwenden
# - Parsing der Antworten robuster machen
def process_wiki_verification(crm_data, wiki_data_str):
# Platzhalter - Implementierung anpassen oder entfernen, falls durch _process_batch abgedeckt
debug_print(f"TODO: process_wiki_verification aufrufen/implementieren für {crm_data}")
return "k.A. (Not Implemented)"
def evaluate_fsm_suitability(company_name, company_data):
# Platzhalter - Implementierung anpassen
debug_print(f"TODO: evaluate_fsm_suitability aufrufen/implementieren für {company_name}")
return {"suitability": "k.A.", "justification": "Not Implemented"}
def evaluate_servicetechnicians_estimate(company_name, company_data):
# Platzhalter - Implementierung anpassen
debug_print(f"TODO: evaluate_servicetechnicians_estimate aufrufen/implementieren für {company_name}")
return "k.A. (Not Implemented)"
def map_internal_technicians(value):
# Platzhalter - Implementierung anpassen
debug_print(f"TODO: map_internal_technicians aufrufen/implementieren für {value}")
return "k.A. (Not Implemented)"
def evaluate_servicetechnicians_explanation(company_name, st_estimate, company_data):
# Platzhalter - Implementierung anpassen
debug_print(f"TODO: evaluate_servicetechnicians_explanation aufrufen/implementieren für {company_name}")
return "k.A. (Not Implemented)"
def process_employee_estimation(company_name, wiki_paragraph, crm_employee):
# Platzhalter - Implementierung anpassen
debug_print(f"TODO: process_employee_estimation aufrufen/implementieren für {company_name}")
return "k.A. (Not Implemented)"
def process_employee_consistency(crm_employee, wiki_employee, emp_estimate):
# Platzhalter - Implementierung anpassen
debug_print(f"TODO: process_employee_consistency aufrufen/implementieren für {crm_employee} vs {wiki_employee} vs {emp_estimate}")
return "k.A. (Not Implemented)"
def evaluate_umsatz_chatgpt(company_name, wiki_umsatz):
# Platzhalter - Implementierung anpassen
debug_print(f"TODO: evaluate_umsatz_chatgpt aufrufen/implementieren für {company_name}")
return "k.A. (Not Implemented)"
# ==================== BATCH PROCESSING FUNCTIONS ====================
def _process_batch(sheet, batches, row_numbers):
"""
Hilfsfunktion für process_verification_only: Verarbeitet einen Batch von Wikipedia-Verifizierungsanfragen.
Aktualisiert NUR die Spalten S bis Y. Zeitstempel (AN, AO, AP) werden von
der aufrufenden Funktion oder anderen Prozessen gesetzt.
Args:
sheet (gspread.Worksheet): Das Worksheet-Objekt zum Schreiben.
batches (list): Liste der Prompt-Teile für den Batch.
row_numbers (list): Liste der zugehörigen Zeilennummern.
"""
if not batches:
return
# --- Prompt Erstellung (wie gehabt) ---
aggregated_prompt = (
"Du bist ein Experte in der Verifizierung von Wikipedia-Artikeln für Unternehmen. "
"Für jeden der folgenden Einträge prüfe, ob der vorhandene Wikipedia-Artikel (URL, Absatz, Kategorien) plausibel zum Firmennamen und zur Beschreibung passt. "
"Gib das Ergebnis für jeden Eintrag ausschließlich im folgenden Format auf einer neuen Zeile aus:\n"
"Eintrag <Zeilennummer>: <Antwort>\n\n"
"Mögliche Antworten:\n"
"- 'OK' (wenn der Artikel gut passt)\n"
"- 'X | Alternativer Artikel: <URL> | Begründung: <Kurze Begründung>' (wenn der Artikel nicht passt, aber ein besserer gefunden wurde)\n"
"- 'X | Kein passender Artikel gefunden | Begründung: <Kurze Begründung>' (wenn der Artikel nicht passt und kein besserer gefunden wurde)\n"
"- 'Kein Wikipedia-Eintrag vorhanden.' (wenn initial keine URL angegeben wurde und keine Suche erfolgreich war)\n\n"
"Einträge:\n"
"----------\n"
)
aggregated_prompt += "".join(batches) # Join ohne zusätzliches \n
aggregated_prompt += "----------\nBitte nur die 'Eintrag X: Antwort'-Zeilen ausgeben."
debug_print(f"Verarbeite Verifizierungs-Batch für Zeilen {row_numbers[0]} bis {row_numbers[-1]} (nur S-Y)...") # Hinweis angepasst
# Token Count für den Prompt
prompt_tokens = token_count(aggregated_prompt)
debug_print(f"Token-Zahl für Verifizierungs-Batch: {prompt_tokens}")
# --- ChatGPT Aufruf (wie gehabt) ---
chat_response = call_openai_chat(aggregated_prompt, temperature=0.0)
if not chat_response:
debug_print(f"Fehler: Keine Antwort von OpenAI für Verifizierungs-Batch {row_numbers[0]}-{row_numbers[-1]}.")
return
# --- Antwort parsen (wie gehabt) ---
answers = {}
lines = chat_response.strip().split('\n')
for line in lines:
match = re.match(r"Eintrag (\d+): (.*)", line.strip())
if match:
row_num = int(match.group(1))
answer_text = match.group(2).strip()
if row_num in row_numbers:
answers[row_num] = answer_text
# else: # Weniger Lärm im Log
# debug_print(f"Warnung: Antwort für unerwartete Zeilennummer {row_num} im Batch erhalten: {answer_text}")
# --- Batch-Update vorbereiten (NUR S bis Y) ---
updates = []
# Timestamps und Version werden HIER NICHT mehr hinzugefügt
for row_num in row_numbers:
answer = answers.get(row_num, "k.A. (Keine Antwort im Batch)") # Fallback
# Variablen für die Spalten S-Y initialisieren
wiki_confirm = "" # Spalte S
alt_article = "" # Spalte T
wiki_explanation = "" # Spalte U
v_val, w_val, x_val, y_val = "", "", "", "" # Spalten V-Y
# Logik zur Bestimmung der Werte für S, T, U basierend auf 'answer' (wie gehabt)
if answer.upper() == "OK":
wiki_confirm = "OK"
elif answer.upper() == "KEIN WIKIPEDIA-EINTRAG VORHANDEN.":
wiki_confirm = "X"
alt_article = "Kein Wikipedia-Eintrag vorhanden."
wiki_explanation = "Ursprünglich keine URL oder Suche erfolglos."
elif answer.startswith("X |"):
parts = answer.split("|", 2)
wiki_confirm = "X"
if len(parts) > 1:
detail = parts[1].strip()
if detail.startswith("Alternativer Artikel:"):
alt_article = detail.split(":", 1)[1].strip()
elif detail == "Kein passender Artikel gefunden":
alt_article = detail
else:
alt_article = detail
if len(parts) > 2:
reason_part = parts[2].strip()
if reason_part.startswith("Begründung:"):
wiki_explanation = reason_part.split(":", 1)[1].strip()
else:
wiki_explanation = reason_part
else: # Unerwartetes Format
wiki_confirm = "?"
wiki_explanation = f"Unerwartetes Format: {answer}"
# Füge Updates NUR für S-Y zur Liste hinzu
updates.append({'range': f'S{row_num}', 'values': [[wiki_confirm]]})
updates.append({'range': f'T{row_num}', 'values': [[alt_article]]})
updates.append({'range': f'U{row_num}', 'values': [[wiki_explanation]]})
updates.append({'range': f'V{row_num}:Y{row_num}', 'values': [[v_val, w_val, x_val, y_val]]})
# --- Führe das Batch-Update für S-Y durch ---
if updates:
try:
# Verwende das übergebene sheet-Objekt direkt
sheet.batch_update(updates, value_input_option='USER_ENTERED')
debug_print(f"Verifizierungs-Batch {row_numbers[0]}-{row_numbers[-1]} (S-Y) erfolgreich in Google Sheet aktualisiert.")
except Exception as e:
# Gib eine spezifischere Fehlermeldung aus
debug_print(f"FEHLER beim Batch-Update (S-Y) für Zeilen {row_numbers[0]}-{row_numbers[-1]}: {type(e).__name__} - {e}")
# Optional: Fehler weitergeben, wenn retry gewünscht wird (nicht empfohlen für Sheet-Updates hier)
# raise e
else:
debug_print(f"Keine Updates (S-Y) für Verifizierungs-Batch {row_numbers[0]}-{row_numbers[-1]} generiert.")
# KEINE Pause hier mehr, wird in der aufrufenden Funktion gemacht
# time.sleep(Config.RETRY_DELAY)
def process_verification_only(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet):
"""
Batch-Prozess nur für Wikipedia-Verifizierung (Spalten S-Y).
Lädt Daten neu, prüft für jede Zeile im Bereich, ob Timestamp AX (Wiki Verif.)
bereits gesetzt ist, und überspringt diese ggf. Setzt AX für bearbeitete Zeilen.
AN wird hier *nicht* mehr gesetzt, das muss ggf. _process_single_row tun.
"""
debug_print(f"Starte Wikipedia-Verifizierungsmodus (Batch) für Zeilen {start_row_index_in_sheet} bis {end_row_index_in_sheet}...")
if not sheet_handler.load_data():
debug_print("FEHLER beim Laden der Daten in process_verification_only.")
return
all_data = sheet_handler.get_all_data_with_headers()
if not all_data or len(all_data) <= 5:
debug_print("FEHLER/WARNUNG: Keine Daten zum Verarbeiten in process_verification_only gefunden.")
return
# Hole Index für AX Timestamp (Wiki Verif.)
timestamp_col_key = "Wiki Verif. Timestamp" # NEUER SCHLÜSSEL
timestamp_col_index = COLUMN_MAP.get(timestamp_col_key)
ts_col_letter = sheet_handler._get_col_letter(timestamp_col_index + 1) if timestamp_col_index is not None else "AX_FEHLER"
if timestamp_col_index is None:
debug_print(f"FEHLER: Spaltenschlüssel '{timestamp_col_key}' nicht in COLUMN_MAP gefunden. Breche Wiki-Verifizierung ab.")
return
batch_size = Config.BATCH_SIZE
current_batch = []
current_row_numbers = []
processed_count = 0
skipped_count = 0
for i in range(start_row_index_in_sheet, end_row_index_in_sheet + 1):
row_index_in_list = i - 1
if row_index_in_list >= len(all_data): continue
row = all_data[row_index_in_list]
# --- Timestamp-Prüfung für jede Zeile (AX) ---
ts_value_ax = "INDEX_FEHLER"
ts_ax_is_set = False
if len(row) > timestamp_col_index:
ts_value_ax = row[timestamp_col_index]
ts_ax_is_set = bool(str(ts_value_ax).strip())
log_debug = (i < start_row_index_in_sheet + 5 or i > end_row_index_in_sheet - 5 or i % 500 == 0 or i in range(2122, 2132))
if log_debug:
debug_print(f"Zeile {i} (Wiki Verif. Check): Prüfe Timestamp {ts_col_letter} (Index {timestamp_col_index}). Rohwert='{ts_value_ax}', Strip='{str(ts_value_ax).strip()}', Überspringen? -> {ts_ax_is_set}")
if ts_ax_is_set:
skipped_count += 1
continue
# --- Ende Timestamp-Prüfung ---
# Nur wenn AX leer ist, wird die Zeile für den Batch vorbereitet
company_name = row[COLUMN_MAP.get("CRM Name", 1)] if len(row) > COLUMN_MAP.get("CRM Name", 1) else ''
crm_desc = row[COLUMN_MAP.get("CRM Beschreibung", 5)] if len(row) > COLUMN_MAP.get("CRM Beschreibung", 5) else ''
wiki_url_idx = COLUMN_MAP.get("Wiki URL")
wiki_url = row[wiki_url_idx] if wiki_url_idx is not None and len(row) > wiki_url_idx and row[wiki_url_idx].strip() not in ['', 'k.A.'] else 'k.A.'
wiki_para_idx = COLUMN_MAP.get("Wiki Absatz")
wiki_paragraph = row[wiki_para_idx] if wiki_para_idx is not None and len(row) > wiki_para_idx else 'k.A.'
wiki_cat_idx = COLUMN_MAP.get("Wiki Kategorien")
wiki_categories = row[wiki_cat_idx] if wiki_cat_idx is not None and len(row) > wiki_cat_idx else 'k.A.'
entry_text = (
f"Eintrag {i}:\n"
f" Firmenname: {company_name}\n"
f" CRM-Beschreibung: {crm_desc[:200]}...\n"
f" Wikipedia-URL: {wiki_url}\n"
f" Wiki-Absatz: {wiki_paragraph[:200]}...\n"
f" Wiki-Kategorien: {wiki_categories[:200]}...\n"
f"----\n"
)
current_batch.append(entry_text)
current_row_numbers.append(i)
processed_count += 1
if len(current_batch) >= batch_size or i == end_row_index_in_sheet:
if current_batch:
# Rufe _process_batch auf (schreibt S-Y)
_process_batch(sheet_handler.sheet, current_batch, current_row_numbers)
# Setze den AX Timestamp für die bearbeiteten Zeilen
wiki_ts_updates = []
current_wiki_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
for row_num in current_row_numbers:
wiki_ts_updates.append({'range': f'{ts_col_letter}{row_num}', 'values': [[current_wiki_timestamp]]})
if wiki_ts_updates:
success_ts = sheet_handler.batch_update_cells(wiki_ts_updates)
if success_ts:
debug_print(f"Wiki Verif. Timestamp {ts_col_letter} für Batch {current_row_numbers[0]}-{current_row_numbers[-1]} gesetzt.")
else:
debug_print(f"FEHLER beim Setzen des Wiki Verif. Timestamps {ts_col_letter} für Batch.")
time.sleep(Config.RETRY_DELAY)
current_batch = []
current_row_numbers = []
debug_print(f"Wikipedia-Verifizierungs-Batch abgeschlossen. {processed_count} Zeilen zur Verarbeitung weitergegeben, {skipped_count} Zeilen übersprungen.")
# Anpassung in _process_batch: Setzt jetzt *nicht* mehr AO/AP, sondern nur S-Y
def _process_batch(sheet, batches, row_numbers):
"""
Hilfsfunktion für process_verification_only: Verarbeitet einen Batch von Wikipedia-Verifizierungsanfragen.
Aktualisiert NUR die Spalten S bis Y. Zeitstempel werden von der aufrufenden Funktion gesetzt.
"""
if not batches: return
# (Prompt Erstellung wie gehabt)
aggregated_prompt = (
"Du bist ein Experte in der Verifizierung von Wikipedia-Artikeln für Unternehmen. "
"Für jeden der folgenden Einträge prüfe, ob der vorhandene Wikipedia-Artikel (URL, Absatz, Kategorien) plausibel zum Firmennamen und zur Beschreibung passt. "
"Gib das Ergebnis für jeden Eintrag ausschließlich im folgenden Format auf einer neuen Zeile aus:\n"
"Eintrag <Zeilennummer>: <Antwort>\n\n"
"Mögliche Antworten:\n"
"- 'OK' (wenn der Artikel gut passt)\n"
"- 'X | Alternativer Artikel: <URL> | Begründung: <Kurze Begründung>' (wenn der Artikel nicht passt, aber ein besserer gefunden wurde)\n"
"- 'X | Kein passender Artikel gefunden | Begründung: <Kurze Begründung>' (wenn der Artikel nicht passt und kein besserer gefunden wurde)\n"
"- 'Kein Wikipedia-Eintrag vorhanden.' (wenn initial keine URL angegeben wurde und keine Suche erfolgreich war - dieser Fall sollte selten sein, da die Suche vorher stattfindet)\n\n"
"Einträge:\n"
"----------\n"
)
aggregated_prompt += "".join(batches)
aggregated_prompt += "----------\nBitte nur die 'Eintrag X: Antwort'-Zeilen ausgeben."
debug_print(f"Verarbeite Verifizierungs-Batch für Zeilen {row_numbers[0]} bis {row_numbers[-1]}.")
prompt_tokens = token_count(aggregated_prompt)
debug_print(f"Token-Zahl für Verifizierungs-Batch: {prompt_tokens}")
chat_response = call_openai_chat(aggregated_prompt, temperature=0.0)
if not chat_response:
debug_print(f"Fehler: Keine Antwort von OpenAI für Verifizierungs-Batch {row_numbers[0]}-{row_numbers[-1]}.")
return
# Parse die aggregierte Antwort (wie gehabt)
answers = {}
lines = chat_response.strip().split('\n')
for line in lines:
match = re.match(r"Eintrag (\d+): (.*)", line.strip())
if match:
row_num = int(match.group(1))
answer_text = match.group(2).strip()
if row_num in row_numbers: answers[row_num] = answer_text
# Bereite Batch-Update nur für Spalten S-Y vor
updates = []
# current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") # Nicht mehr hier
# current_version = Config.VERSION # Nicht mehr hier
for row_num in row_numbers:
answer = answers.get(row_num, "k.A. (Keine Antwort im Batch)")
# debug_print(f"Zeile {row_num} Verifizierungsantwort: '{answer}'") # Optional weniger Lärm
wiki_confirm, alt_article, wiki_explanation = "", "", ""
v_val, w_val, x_val, y_val = "", "", "", ""
if answer.upper() == "OK": wiki_confirm = "OK"
elif answer.upper() == "KEIN WIKIPEDIA-EINTRAG VORHANDEN.":
wiki_confirm, alt_article, wiki_explanation = "X", "Kein Wikipedia-Eintrag vorhanden.", "Ursprünglich keine URL oder Suche erfolglos."
elif answer.startswith("X |"):
parts = answer.split("|", 2)
wiki_confirm = "X"
if len(parts) > 1:
detail = parts[1].strip()
if detail.startswith("Alternativer Artikel:"): alt_article = detail.split(":", 1)[1].strip()
elif detail == "Kein passender Artikel gefunden": alt_article = detail
else: alt_article = detail
if len(parts) > 2:
reason_part = parts[2].strip()
if reason_part.startswith("Begründung:"): wiki_explanation = reason_part.split(":", 1)[1].strip()
else: wiki_explanation = reason_part
else:
wiki_confirm, wiki_explanation = "?", f"Unerwartetes Format: {answer}"
# Füge Updates für S-Y hinzu
updates.append({'range': f'S{row_num}', 'values': [[wiki_confirm]]})
updates.append({'range': f'T{row_num}', 'values': [[alt_article]]})
updates.append({'range': f'U{row_num}', 'values': [[wiki_explanation]]})
updates.append({'range': f'V{row_num}:Y{row_num}', 'values': [[v_val, w_val, x_val, y_val]]})
# Führe das Batch-Update für S-Y durch
if updates:
# Direkten Sheet-Zugriff nutzen, da sheet übergeben wird
try:
sheet.batch_update(updates)
debug_print(f"Verifizierungs-Batch {row_numbers[0]}-{row_numbers[-1]} (S-Y) erfolgreich in Google Sheet aktualisiert.")
except Exception as e:
debug_print(f"FEHLER beim Batch-Update (S-Y) für Batch {row_numbers[0]}-{row_numbers[-1]}: {e}")
else:
debug_print(f"Keine Updates (S-Y) für Verifizierungs-Batch {row_numbers[0]}-{row_numbers[-1]} generiert.")
# Kurze Pause nach jedem Batch-API-Call (jetzt in der aufrufenden Funktion)
# time.sleep(Config.RETRY_DELAY) # Entfernt
# Komplette Funktion process_website_batch (prüft jetzt Timestamp AT mit erzwungenem Debugging)
# Komplette Funktion process_website_batch (MIT Batched Google Sheet Updates)
# Komplette Funktion process_website_batch (NEUE STRUKTUR - ECHTER BATCH WORKFLOW)
# Komplette Funktion process_website_batch (NUR SCRAPING)
# Komplette Funktion process_website_batch (Korrigierte Config-Referenzen)
def process_website_batch(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet):
"""
Batch-Prozess NUR für Website-Scraping (Rohtext AR).
Lädt Daten neu, prüft Spalte AR auf Inhalt ('', 'k.A.', etc.) und überspringt Zeilen mit Inhalt.
Setzt AR + AP für bearbeitete Zeilen. Sendet Updates gebündelt.
"""
debug_print(f"Starte Website-Scraping NUR ROHDATEN (Batch) für Zeilen {start_row_index_in_sheet} bis {end_row_index_in_sheet}...")
# --- Lade Daten ---
if not sheet_handler.load_data(): return
all_data = sheet_handler.get_all_data_with_headers()
if not all_data or len(all_data) <= 5: return
header_rows = 5
# --- Indizes und Buchstaben ---
rohtext_col_key = "Website Rohtext"
rohtext_col_index = COLUMN_MAP.get(rohtext_col_key)
website_col_idx = COLUMN_MAP.get("CRM Website")
version_col_idx = COLUMN_MAP.get("Version")
if None in [rohtext_col_index, website_col_idx, version_col_idx]:
debug_print(f"FEHLER: Benötigte Indizes für process_website_batch fehlen.")
return
rohtext_col_letter = sheet_handler._get_col_letter(rohtext_col_index + 1)
version_col_letter = sheet_handler._get_col_letter(version_col_idx + 1)
# --- Worker-Funktion für Scraping (unverändert) ---
def scrape_raw_text_task(task_info):
row_num = task_info['row_num']; url = task_info['url']; raw_text = "k.A."; error = None
try: raw_text = get_website_raw(url) # Annahme: get_website_raw ist definiert
except Exception as e: error = f"Scraping Fehler Zeile {row_num}: {e}"; debug_print(error)
return {"row_num": row_num, "raw_text": raw_text, "error": error}
# --- Hauptlogik: Iteriere und sammle Batches ---
tasks_for_processing_batch = []
all_sheet_updates = []
total_processed_count = 0
total_skipped_count = 0
total_skipped_url_count = 0
total_error_count = 0
# Werte aus Config holen
processing_batch_size = Config.PROCESSING_BATCH_SIZE
max_scraping_workers = Config.MAX_SCRAPING_WORKERS
update_batch_row_limit = Config.UPDATE_BATCH_ROW_LIMIT
empty_values_for_skip = ["", "k.a.", "k.a. (nur cookie-banner erkannt)", "k.a. (fehler)"]
for i in range(start_row_index_in_sheet, end_row_index_in_sheet + 1):
row_index_in_list = i - 1
if row_index_in_list >= len(all_data): continue
row = all_data[row_index_in_list]
# --- Prüfung, ob AR schon Inhalt hat ---
should_skip = False
cell_value_ar_str_lower = "INDEX_FEHLER"
if len(row) > rohtext_col_index:
cell_value_ar_str_lower = str(row[rohtext_col_index]).strip().lower()
if cell_value_ar_str_lower not in empty_values_for_skip:
should_skip = True
log_debug = (i < start_row_index_in_sheet + 5 or i > end_row_index_in_sheet - 5 or i % 500 == 0)
if log_debug:
debug_print(f"Zeile {i} (Website AR Check): Prüfe Inhalt Spalte {rohtext_col_letter}. Wert='{cell_value_ar_str_lower}'. Überspringen (da schon Inhalt)? -> {should_skip}")
if should_skip:
total_skipped_count += 1
continue
# --- Ende AR Prüfung ---
# URL Prüfung
website_url = row[website_col_idx] if len(row) > website_col_idx else ""
if not website_url or website_url.strip().lower() == "k.a.":
total_skipped_url_count += 1
continue
tasks_for_processing_batch.append({"row_num": i, "url": website_url})
# --- Verarbeitungs-Batch ausführen ---
if len(tasks_for_processing_batch) >= processing_batch_size or i == end_row_index_in_sheet:
if tasks_for_processing_batch:
batch_start_row = tasks_for_processing_batch[0]['row_num']
batch_end_row = tasks_for_processing_batch[-1]['row_num']
batch_task_count = len(tasks_for_processing_batch)
debug_print(f"\n--- Starte Scraping-Batch ({batch_task_count} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---")
scraping_results = {}
batch_error_count = 0 # Fehlerzähler für diesen spezifischen Batch
debug_print(f" Scrape {batch_task_count} Websites parallel (max {max_scraping_workers} worker)...")
with concurrent.futures.ThreadPoolExecutor(max_workers=max_scraping_workers) as executor:
future_to_task = {executor.submit(scrape_raw_text_task, task): task for task in tasks_for_processing_batch}
for future in concurrent.futures.as_completed(future_to_task):
task = future_to_task[future]
# --- KORRIGIERTER TRY-EXCEPT Block ---
try:
result = future.result()
scraping_results[result['row_num']] = result['raw_text']
if result['error']:
batch_error_count += 1
total_error_count += 1
except Exception as exc:
row_num = task['row_num']
err_msg = f"Generischer Fehler Scraping Task Zeile {row_num}: {exc}"
debug_print(err_msg)
scraping_results[row_num] = "k.A. (Fehler)"
batch_error_count += 1
total_error_count +=1
# --- Ende Korrektur ---
current_batch_processed_count = len(scraping_results) # Anzahl Ergebnisse (inkl. Fehler)
total_processed_count += current_batch_processed_count
debug_print(f" Scraping für Batch beendet. {current_batch_processed_count} Ergebnisse erhalten ({batch_error_count} Fehler in diesem Batch).")
# Sheet Updates vorbereiten (AR und AP)
if scraping_results:
current_version = Config.VERSION
batch_sheet_updates = []
for row_num, raw_text_res in scraping_results.items():
# Updates für AR und AP
row_updates = [
{'range': f'{rohtext_col_letter}{row_num}', 'values': [[raw_text_res]]},
{'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]}
]
batch_sheet_updates.extend(row_updates)
all_sheet_updates.extend(batch_sheet_updates) # Sammle für größeren Batch-Update
tasks_for_processing_batch = [] # Batch leeren
# Sheet Updates senden (wenn update_batch_row_limit erreicht)
# Prüfe die Anzahl der *Zeilen*, für die Updates gesammelt wurden
# Da wir jetzt Updates für alle Ergebnisse sammeln, prüfen wir direkt die Länge von all_sheet_updates
if len(all_sheet_updates) >= update_batch_row_limit * 2: # *2 weil 2 Updates pro Zeile
debug_print(f" Sende gesammelte Sheet-Updates ({len(all_sheet_updates)} Zellen)...")
success = sheet_handler.batch_update_cells(all_sheet_updates)
if success: debug_print(f" Sheet-Update bis Zeile {batch_end_row} erfolgreich.") # Logge Endzeile des Batches
else: debug_print(f" FEHLER beim Sheet-Update bis Zeile {batch_end_row}.")
all_sheet_updates = [] # Zurücksetzen nach Senden
# Finale Sheet Updates senden
if all_sheet_updates:
debug_print(f"Sende finale Sheet-Updates ({len(all_sheet_updates)} Zellen)...")
sheet_handler.batch_update_cells(all_sheet_updates)
debug_print(f"Website-Scraping NUR ROHDATEN abgeschlossen. {total_processed_count} Websites verarbeitet (inkl. Fehler), {total_error_count} Fehler, {total_skipped_count} Zeilen wg. Inhalt übersprungen, {total_skipped_url_count} Zeilen ohne URL übersprungen.")
# NEUE Funktion process_website_summarization_batch
def process_website_summarization_batch(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet):
"""
Batch-Prozess NUR für Website-Zusammenfassung (AS).
Lädt Daten neu, prüft, ob Rohtext (AR) vorhanden und Zusammenfassung (AS) fehlt.
Fasst Rohtexte im Batch via OpenAI zusammen und setzt AS + AP.
"""
debug_print(f"Starte Website-Zusammenfassung (OpenAI Batch) für Zeilen {start_row_index_in_sheet} bis {end_row_index_in_sheet}...")
# --- Konfiguration ---
openai_batch_size = Config.OPENAI_BATCH_SIZE_LIMIT # Holt Wert aus Config (jetzt z.B. 1)
update_batch_row_limit = Config.UPDATE_BATCH_ROW_LIMIT # z.B. 50
# --- Lade Daten ---
if not sheet_handler.load_data(): return
all_data = sheet_handler.get_all_data_with_headers()
if not all_data or len(all_data) <= 5: return
header_rows = 5
# --- Indizes und Buchstaben ---
rohtext_col_idx = COLUMN_MAP.get("Website Rohtext")
summary_col_idx = COLUMN_MAP.get("Website Zusammenfassung")
version_col_idx = COLUMN_MAP.get("Version")
if None in [rohtext_col_idx, summary_col_idx, version_col_idx]: return debug_print(f"FEHLER: Indizes fehlen.")
summary_col_letter = sheet_handler._get_col_letter(summary_col_idx + 1)
version_col_letter = sheet_handler._get_col_letter(version_col_idx + 1)
# --- Verarbeitung ---
tasks_for_openai_batch = []
all_sheet_updates = []
rows_in_current_update_batch = 0
processed_count = 0
skipped_no_rohtext = 0
skipped_summary_exists = 0
for i in range(start_row_index_in_sheet, end_row_index_in_sheet + 1):
row_index_in_list = i - 1
if row_index_in_list >= len(all_data): continue
row = all_data[row_index_in_list]
# Prüfung 1: Ist Rohtext vorhanden und gültig?
raw_text = ""
if len(row) > rohtext_col_idx: raw_text = str(row[rohtext_col_idx]).strip()
if not raw_text or raw_text == "k.A." or raw_text == "k.A. (Nur Cookie-Banner erkannt)" or raw_text == "k.A. (Fehler)":
skipped_no_rohtext += 1; continue
# Prüfung 2: Fehlt die Zusammenfassung (AS)?
summary_exists = False
if len(row) > summary_col_idx and str(row[summary_col_idx]).strip() and str(row[summary_col_idx]).strip() != "k.A.":
summary_exists = True
if summary_exists: skipped_summary_exists += 1; continue
# Task hinzufügen
tasks_for_openai_batch.append({'row_num': i, 'raw_text': raw_text})
processed_count += 1
# --- OpenAI Batch verarbeiten, wenn voll oder letzte Zeile ---
if tasks_for_openai_batch and \
(len(tasks_for_openai_batch) >= openai_batch_size or (processed_count > 0 and i == end_row_index_in_sheet)):
debug_print(f" Verarbeite OpenAI Batch für {len(tasks_for_openai_batch)} Aufgaben (Start: {tasks_for_openai_batch[0]['row_num']})...")
summaries_result = summarize_batch_openai(tasks_for_openai_batch) # Ruft modifizierte Funktion auf
# Sheet Updates für diesen OpenAI Batch vorbereiten
current_version = Config.VERSION
for task in tasks_for_openai_batch: # Iteriere über die *gesendeten* Tasks
row_num = task['row_num']
summary = summaries_result.get(row_num, "k.A. (Fehler Batch Zuordnung)")
row_updates = [
{'range': f'{summary_col_letter}{row_num}', 'values': [[summary]]},
{'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]}
]
all_sheet_updates.extend(row_updates)
rows_in_current_update_batch += 1
tasks_for_openai_batch = [] # OpenAI Batch leeren
# --- Gesammelte Sheet Updates senden ---
if all_sheet_updates and \
(rows_in_current_update_batch >= update_batch_row_limit or (processed_count > 0 and i == end_row_index_in_sheet)):
debug_print(f" Sende Sheet-Update für {rows_in_current_update_batch} Zusammenfassungen...")
success = sheet_handler.batch_update_cells(all_sheet_updates)
if success: debug_print(f" Sheet-Update bis Zeile {i} erfolgreich.")
else: debug_print(f" FEHLER beim Sheet-Update bis Zeile {i}.")
all_sheet_updates = []; rows_in_current_update_batch = 0
# Letzten Sheet Update Batch senden
if all_sheet_updates:
debug_print(f"Sende LETZTES Sheet-Update für {rows_in_current_update_batch} Zusammenfassungen...")
sheet_handler.batch_update_cells(all_sheet_updates)
debug_print(f"Website-Zusammenfassungs-Batch abgeschlossen. {processed_count} Zusammenfassungen angefordert, {skipped_no_rohtext} wg. fehlendem Rohtext übersprungen, {skipped_summary_exists} wg. vorhandener Zusammenfassung übersprungen.")
# Komplette Funktion process_branch_batch (prüft jetzt Timestamp AO mit erzwungenem Debugging)
# Komplette Funktion process_branch_batch (MIT Korrektur und Prüfung auf AO)
def process_branch_batch(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet):
"""
Batch-Prozess für Brancheneinschätzung mit paralleler Verarbeitung via Threads.
Prüft Timestamp AO, führt evaluate_branche_chatgpt parallel aus (limitiert),
setzt W, X, Y, AO + AP und sendet Sheet-Updates GEBÜNDELT PRO VERARBEITUNGS-BATCH.
"""
debug_print(f"Starte Brancheneinschätzung (Parallel Batch) für Zeilen {start_row_index_in_sheet} bis {end_row_index_in_sheet}...")
if not sheet_handler.load_data(): return
all_data = sheet_handler.get_all_data_with_headers()
if not all_data or len(all_data) <= 5: return
header_rows = 5
# --- Indizes und Buchstaben ---
timestamp_col_key = "Timestamp letzte Prüfung"; timestamp_col_index = COLUMN_MAP.get(timestamp_col_key)
branche_crm_idx = COLUMN_MAP.get("CRM Branche"); beschreibung_idx = COLUMN_MAP.get("CRM Beschreibung")
branche_wiki_idx = COLUMN_MAP.get("Wiki Branche"); kategorien_wiki_idx = COLUMN_MAP.get("Wiki Kategorien")
summary_web_idx = COLUMN_MAP.get("Website Zusammenfassung"); version_col_idx = COLUMN_MAP.get("Version")
branch_w_idx = COLUMN_MAP.get("Chat Vorschlag Branche"); branch_x_idx = COLUMN_MAP.get("Chat Konsistenz Branche")
branch_y_idx = COLUMN_MAP.get("Chat Begründung Abweichung Branche")
required_indices = [timestamp_col_index, branche_crm_idx, beschreibung_idx, branche_wiki_idx, kategorien_wiki_idx, summary_web_idx, version_col_idx, branch_w_idx, branch_x_idx, branch_y_idx]
if None in required_indices: return debug_print(f"FEHLER: Indizes fehlen.")
ts_col_letter = sheet_handler._get_col_letter(timestamp_col_index + 1)
version_col_letter = sheet_handler._get_col_letter(version_col_idx + 1)
branch_w_letter = sheet_handler._get_col_letter(branch_w_idx + 1)
branch_x_letter = sheet_handler._get_col_letter(branch_x_idx + 1)
branch_y_letter = sheet_handler._get_col_letter(branch_y_idx + 1)
# --- Konfiguration ---
MAX_BRANCH_WORKERS = Config.MAX_BRANCH_WORKERS
OPENAI_CONCURRENCY_LIMIT = Config.OPENAI_CONCURRENCY_LIMIT
openai_semaphore_branch = threading.Semaphore(OPENAI_CONCURRENCY_LIMIT)
PROCESSING_BRANCH_BATCH_SIZE = Config.PROCESSING_BRANCH_BATCH_SIZE
update_batch_row_limit = Config.UPDATE_BATCH_ROW_LIMIT # Wird derzeit nicht verwendet, da wir pro Batch senden
# --- Worker Funktion ---
def evaluate_branch_task(task_data):
row_num = task_data['row_num']; result = {"branch": "k.A. (Fehler Task)", "consistency": "error", "justification": "Fehler in Worker-Task"}; error = None
try:
with openai_semaphore_branch:
# debug_print(f" Task {row_num}: Warte auf Semaphore...") # Sehr detailliertes Logging
# time.sleep(0.1) # Minimale Pause reduziert manchmal Race Conditions bei hoher Last
# debug_print(f" Task {row_num}: Semaphore erhalten, starte evaluate_branche_chatgpt...")
result = evaluate_branche_chatgpt( task_data['crm_branche'], task_data['beschreibung'], task_data['wiki_branche'], task_data['wiki_kategorien'], task_data['website_summary'])
# debug_print(f" Task {row_num}: evaluate_branche_chatgpt beendet.")
except Exception as e:
error = f"Fehler bei Branchenevaluation Zeile {row_num}: {e}"; debug_print(error); result['justification'] = error[:500]; result['consistency'] = 'error_task'
return {"row_num": row_num, "result": result, "error": error}
# --- Hauptverarbeitung ---
tasks_for_processing_batch = []
total_processed_count = 0; total_skipped_count = 0; total_error_count = 0
if not ALLOWED_TARGET_BRANCHES: load_target_schema();
if not ALLOWED_TARGET_BRANCHES: return debug_print("FEHLER: Ziel-Schema nicht geladen.")
for i in range(start_row_index_in_sheet, end_row_index_in_sheet + 1):
row_index_in_list = i - 1
if row_index_in_list >= len(all_data): continue
row = all_data[row_index_in_list]
# Timestamp-Prüfung (AO)
should_skip = False
if len(row) > timestamp_col_index and str(row[timestamp_col_index]).strip(): should_skip = True
if should_skip: total_skipped_count += 1; continue
# Task sammeln
task_data = { "row_num": i, "crm_branche": row[branche_crm_idx] if len(row) > branche_crm_idx else "", "beschreibung": row[beschreibung_idx] if len(row) > beschreibung_idx else "", "wiki_branche": row[branche_wiki_idx] if len(row) > branche_wiki_idx else "", "wiki_kategorien": row[kategorien_wiki_idx] if len(row) > kategorien_wiki_idx else "", "website_summary": row[summary_web_idx] if len(row) > summary_web_idx else ""}
tasks_for_processing_batch.append(task_data)
# --- Verarbeitungs-Batch ausführen ---
if len(tasks_for_processing_batch) >= PROCESSING_BRANCH_BATCH_SIZE or i == end_row_index_in_sheet:
if tasks_for_processing_batch:
batch_start_row = tasks_for_processing_batch[0]['row_num']; batch_end_row = tasks_for_processing_batch[-1]['row_num']
batch_task_count = len(tasks_for_processing_batch)
debug_print(f"\n--- Starte Branch-Evaluation Batch ({batch_task_count} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---")
results_list = []; batch_error_count = 0
debug_print(f" Evaluiere {batch_task_count} Zeilen parallel (max {MAX_BRANCH_WORKERS} worker, {OPENAI_CONCURRENCY_LIMIT} OpenAI gleichzeitig)...")
# *** BEGINN PARALLELE VERARBEITUNG ***
with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_BRANCH_WORKERS) as executor:
future_to_task = {executor.submit(evaluate_branch_task, task): task for task in tasks_for_processing_batch}
# Warte auf Ergebnisse und sammle sie
for future in concurrent.futures.as_completed(future_to_task):
task = future_to_task[future]
try: result_data = future.result(); results_list.append(result_data);
except Exception as exc:
row_num = task['row_num']; err_msg = f"Generischer Fehler Branch Task Zeile {row_num}: {exc}"; debug_print(err_msg)
results_list.append({"row_num": row_num, "result": {"branch": "FEHLER", "consistency": "error_task", "justification": err_msg[:500]}, "error": err_msg})
batch_error_count += 1; total_error_count +=1
# Zähle Fehler aus dem Ergebnis-Dict
if results_list[-1]['error']: batch_error_count += 1; total_error_count +=1
# *** ENDE PARALLELE VERARBEITUNG ***
current_batch_processed_count = len(results_list)
total_processed_count += current_batch_processed_count
debug_print(f" Branch-Evaluation für Batch beendet. {current_batch_processed_count} Ergebnisse erhalten ({batch_error_count} Fehler in diesem Batch).")
# Sheet Updates vorbereiten FÜR DIESEN BATCH
if results_list:
current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
current_version = Config.VERSION
batch_sheet_updates = []
# Sortiere Ergebnisse nach Zeilennummer für geordnetes Schreiben (optional)
results_list.sort(key=lambda x: x['row_num'])
for res_data in results_list:
row_num = res_data['row_num']; result = res_data['result']
# Logge das individuelle Ergebnis VOR dem Update
debug_print(f" Zeile {row_num}: Ergebnis -> Branch='{result.get('branch')}', Consistency='{result.get('consistency')}', Justification='{result.get('justification', '')[:50]}...'")
row_updates = [
{'range': f'{branch_w_letter}{row_num}', 'values': [[result.get("branch", "Fehler")]]},
{'range': f'{branch_x_letter}{row_num}', 'values': [[result.get("consistency", "Fehler")]]},
{'range': f'{branch_y_letter}{row_num}', 'values': [[result.get("justification", "Fehler")]]},
{'range': f'{ts_col_letter}{row_num}', 'values': [[current_timestamp]]},
{'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]}
]
batch_sheet_updates.extend(row_updates)
# Sende Updates für DIESEN Batch SOFORT
if batch_sheet_updates:
debug_print(f" Sende Sheet-Update für {len(results_list)} Zeilen ({len(batch_sheet_updates)} Zellen)...")
success = sheet_handler.batch_update_cells(batch_sheet_updates)
if success: debug_print(f" Sheet-Update für Batch {batch_start_row}-{batch_end_row} erfolgreich.")
else: debug_print(f" FEHLER beim Sheet-Update für Batch {batch_start_row}-{batch_end_row}.")
else: debug_print(f" Keine Sheet-Updates für Batch {batch_start_row}-{batch_end_row} vorbereitet.")
tasks_for_processing_batch = [] # Batch leeren
debug_print(f"--- Verarbeitungs-Batch {batch_start_row}-{batch_end_row} abgeschlossen ---")
# Kurze Pause NACHDEM ein Batch komplett verarbeitet und geschrieben wurde
# debug_print(" Warte 1 Sekunde...") # Test-Log
time.sleep(1)
# !!! HIER DARF KEIN SLEEP STEHEN !!!
# time.sleep(Config.RETRY_DELAY) # <<< DIESE ZEILE MUSS WEG SEIN in deinem Code!
debug_print(f"Brancheneinschätzung (Parallel Batch) abgeschlossen. {total_processed_count} Zeilen verarbeitet (inkl. Fehler), {total_error_count} Fehler, {total_skipped_count} Zeilen wg. Timestamp übersprungen.")
# Annahmen:
# - Funktionen debug_print, process_verification_only, process_website_batch, process_branch_batch sind definiert.
# - sheet_handler ist eine initialisierte Instanz von GoogleSheetHandler (mit der korrekten get_start_row_index Methode).
# - Globale Konstante header_rows (oder besser, hol sie vom sheet_handler?)
# Komplette run_dispatcher Funktion (Start immer basierend auf AO)
# Komplette run_dispatcher Funktion (Keine Änderungen hier nötig)
def run_dispatcher(mode, sheet_handler, row_limit=None):
"""
Wählt den passenden Batch-Prozess basierend auf dem Modus.
Ermittelt die Startzeile dynamisch basierend auf der relevanten Spalte für den Modus.
"""
debug_print(f"Starte Dispatcher im Modus '{mode}' mit row_limit={row_limit}.")
header_rows = 5
# Startspalte für jeden Modus
start_col_key = "Timestamp letzte Prüfung" # Standard AO
min_start_row = 7
if mode == "website": start_col_key = "Website Rohtext" # AR
elif mode == "wiki": start_col_key = "Wiki Verif. Timestamp" # AX
elif mode == "branch": start_col_key = "Timestamp letzte Prüfung" # AO
elif mode == "summarize": start_col_key = "Website Zusammenfassung" # AS
elif mode == "combined": start_col_key = "Timestamp letzte Prüfung" # AO
debug_print(f"Dispatcher: Ermittle Startzeile basierend auf Spalte '{start_col_key}'...")
# get_start_row_index prüft jetzt auf exakt ""
start_data_index = sheet_handler.get_start_row_index(check_column_key=start_col_key, min_sheet_row=min_start_row)
if start_data_index == -1: return debug_print(f"FEHLER: Startspalte '{start_col_key}' prüfen!")
start_row_index_in_sheet = start_data_index + header_rows + 1
total_sheet_rows = len(sheet_handler.sheet_values)
# Prüfungen (wie gehabt)
if start_data_index >= len(sheet_handler.get_data()): return debug_print("Start nach Ende.")
if start_row_index_in_sheet > total_sheet_rows: return debug_print("Ungültige Startzeile.")
# Endzeile
if row_limit is not None and row_limit > 0: end_row_index_in_sheet = min(start_row_index_in_sheet + row_limit - 1, total_sheet_rows)
elif row_limit == 0: return debug_print("Limit 0.")
else: end_row_index_in_sheet = total_sheet_rows
debug_print(f"Dispatcher: Verarbeitung geplant für Sheet-Zeilen {start_row_index_in_sheet} bis {end_row_index_in_sheet}.")
if start_row_index_in_sheet > end_row_index_in_sheet: return debug_print("Start nach Ende (berechnet).")
# Modusauswahl
try:
if mode == "wiki": process_verification_only(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet)
elif mode == "website": process_website_batch(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet) # Prüft AR, Setzt AR+AP
elif mode == "branch": process_branch_batch(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet)
elif mode == "summarize": process_website_summarization_batch(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet)
elif mode == "combined":
debug_print("--- Start Combined Mode: Wiki ---"); process_verification_only(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet); time.sleep(1)
debug_print("--- Start Combined Mode: Website Scraping ---"); process_website_batch(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet); time.sleep(1)
debug_print("--- Start Combined Mode: Website Summarization ---"); process_website_summarization_batch(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet); time.sleep(1)
debug_print("--- Start Combined Mode: Branch ---"); process_branch_batch(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet)
debug_print("--- Combined Mode abgeschlossen ---")
else: debug_print(f"Ungültiger Modus '{mode}'.")
except Exception as e: debug_print(f"FEHLER im Dispatcher: {e}"); import traceback; debug_print(traceback.format_exc())
# --- Ende run_dispatcher Funktion ---
# ==================== SERP API / LINKEDIN FUNCTIONS ====================
@retry_on_failure
def serp_website_lookup(company_name):
"""Ermittelt Website via SERP API (Google Suche)."""
serp_key = Config.API_KEYS.get('serpapi')
if not serp_key:
debug_print("Fehler: SerpAPI Key nicht verfügbar für Website Lookup.")
return "k.A."
if not company_name: return "k.A."
# Blacklist unerwünschter Domains
blacklist = ["bloomberg.com", "northdata.de", "finanzen.net", "handelsblatt.com", "wikipedia.org", "linkedin.com"]
query = f'{company_name} offizielle Website' # Präzisere Query
params = {
"engine": "google",
"q": query,
"api_key": serp_key,
"hl": "de",
"gl": "de" # Geolocation auf Deutschland setzen
}
api_url = "https://serpapi.com/search"
try:
response = requests.get(api_url, params=params, timeout=10)
response.raise_for_status()
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"]["website"]
if kg_url and not any(bad_domain in kg_url for bad_domain in blacklist):
normalized_url = simple_normalize_url(kg_url)
if normalized_url != "k.A.":
debug_print(f"SERP Lookup: Website '{normalized_url}' aus Knowledge Graph für '{company_name}' gefunden.")
return normalized_url
# 2. Organische Ergebnisse prüfen
if "organic_results" in data:
for result in data["organic_results"]:
url = result.get("link", "")
# Prüfe Blacklist und ob es eine "echte" Website ist (nicht nur Suche etc.)
if url and not any(bad_domain in url for bad_domain in blacklist) and url.startswith("http"):
normalized_url = simple_normalize_url(url)
if normalized_url != "k.A.":
# Zusätzliche Plausibilitätsprüfung: Enthält die Domain Teile des Firmennamens?
domain_part = normalized_url.replace('www.', '').split('.')[0]
if domain_part in normalize_company_name(company_name):
debug_print(f"SERP Lookup: Website '{normalized_url}' aus Organic Results für '{company_name}' gefunden.")
return normalized_url
else:
debug_print(f"SERP Lookup: URL '{normalized_url}' übersprungen (Domain passt nicht zu '{company_name}').")
debug_print(f"SERP Lookup: Keine passende Website für '{company_name}' gefunden.")
return "k.A."
except requests.exceptions.RequestException as e:
debug_print(f"Fehler beim SERP API Website Lookup für '{company_name}': {e}")
return "k.A."
except Exception as e:
debug_print(f"Allgemeiner Fehler beim SERP API Website Lookup für '{company_name}': {e}")
return "k.A."
@retry_on_failure
def search_linkedin_contacts(company_name, website, position_query, crm_kurzform, num_results=10):
"""Sucht LinkedIn Kontakte via SERP API."""
serp_key = Config.API_KEYS.get('serpapi')
if not serp_key:
debug_print("Fehler: SerpAPI Key nicht verfügbar für LinkedIn Suche.")
return []
if not all([company_name, position_query, crm_kurzform]):
return []
# Query anpassen für bessere Ergebnisse
query = f'site:linkedin.com/in "{position_query}" "{crm_kurzform}"' # Suche nach Kurzform im Titel
# query = f'site:linkedin.com/in "{position_query}" "{company_name}"' # Original Query
params = {
"engine": "google",
"q": query,
"api_key": serp_key,
"hl": "de",
"gl": "de",
"num": num_results # Google's num Parameter (max 100, aber oft weniger geliefert)
}
api_url = "https://serpapi.com/search"
try:
response = requests.get(api_url, params=params, timeout=15) # Längerer Timeout
response.raise_for_status()
data = response.json()
contacts = []
if "organic_results" in data:
for result in data["organic_results"]:
title = result.get("title", "")
linkedin_url = result.get("link", "")
# Filter: Muss LinkedIn URL sein und Kurzform muss im Titel vorkommen
if not linkedin_url or "linkedin.com/in/" not in linkedin_url:
continue
if crm_kurzform.lower() not in title.lower():
debug_print(f"LinkedIn Treffer übersprungen: Kurzform '{crm_kurzform}' nicht in Titel '{title}'")
continue
# Extrahiere Name und Position aus Titel
name_part = ""
pos_part = position_query # Fallback auf Suchbegriff
# Versuche gängige Trennzeichen
separators = ["", "-", "|", " at ", " bei "]
title_cleaned = title.replace("...", "").strip() # Bereinige Titel
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 = name_part.replace(" | LinkedIn", "").replace(" - LinkedIn", "").replace(" - Profil", "").strip()
# Positionsteil kann komplex sein, nehme alles nach dem Trenner
potential_pos = parts[1].strip()
# Entferne Firmenteil, wenn er dem Kurznamen ähnelt
if crm_kurzform.lower() in potential_pos.lower():
potential_pos = potential_pos.replace(crm_kurzform, "", 1).strip() # Nur erste Instanz ersetzen
# Entferne generische Endungen
potential_pos = potential_pos.split(" | LinkedIn")[0].split(" - LinkedIn")[0].strip()
pos_part = potential_pos if potential_pos else position_query
found_sep = True
break
if not found_sep: # Kein Trennzeichen gefunden
name_part = title_cleaned.split(" | LinkedIn")[0].split(" - LinkedIn")[0].strip()
# Prüfe, ob der Suchbegriff im verbleibenden Namensteil ist
if position_query.lower() in name_part.lower():
name_part = name_part.replace(position_query, "", 1).strip() # Versuche Position zu entfernen
# Teile Namen in Vor- und Nachname
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: # Wenn Name nicht extrahiert werden konnte, überspringe
debug_print(f"Kontakt übersprungen: Name konnte nicht extrahiert werden aus Titel '{title}'")
continue
contact_data = {
"Firmenname": company_name, # Originalname für Kontext
"CRM Kurzform": crm_kurzform,
"Website": website,
"Vorname": firstname,
"Nachname": lastname,
"Position": pos_part,
"LinkedInURL": linkedin_url
}
contacts.append(contact_data)
debug_print(f"Gefundener LinkedIn Kontakt: {firstname} {lastname} - {pos_part}")
debug_print(f"LinkedIn Suche für '{position_query}' bei '{crm_kurzform}' ergab {len(contacts)} Kontakte.")
return contacts
except requests.exceptions.RequestException as e:
debug_print(f"Fehler bei der SERP API LinkedIn Suche: {e}")
return []
except Exception as e:
debug_print(f"Allgemeiner Fehler bei der SERP API LinkedIn Suche: {e}")
return []
# Funktion count_linkedin_contacts wurde entfernt, da search_linkedin_contacts jetzt die Liste liefert
# und len() darauf angewendet werden kann.
def process_contact_research(sheet_handler):
"""Sucht LinkedIn Kontakte und trägt sie in 'Contacts' Sheet ein."""
debug_print("Starte Contact Research (LinkedIn)...")
main_sheet = sheet_handler.sheet
all_data = sheet_handler.get_all_data_with_headers()
header_rows = 5
# Finde Startzeile basierend auf Timestamp in Spalte AM (Index 38)
timestamp_col_index = COLUMN_MAP["Contact Search Timestamp"]
start_row_index_in_sheet = -1
for i in range(header_rows + 1, len(all_data) + 1):
if i < 7: continue # Normalerweise ab Zeile 7
row_index_in_list = i - 1
row = all_data[row_index_in_list]
if len(row) <= timestamp_col_index or not row[timestamp_col_index].strip():
start_row_index_in_sheet = i
break
if start_row_index_in_sheet == -1:
debug_print("Keine Zeile ohne Contact Search Timestamp (Spalte AM, ab Zeile 7) gefunden. Überspringe.")
return
debug_print(f"Contact Research startet ab Zeile {start_row_index_in_sheet}.")
# Kontakte-Blatt öffnen oder erstellen
try:
contacts_sheet = sheet_handler.sheet.spreadsheet.worksheet("Contacts")
debug_print("Blatt 'Contacts' gefunden.")
except gspread.exceptions.WorksheetNotFound:
debug_print("Blatt 'Contacts' nicht gefunden, erstelle neu...")
contacts_sheet = sheet_handler.sheet.spreadsheet.add_worksheet(title="Contacts", rows="1000", cols="12")
header = ["Firmenname", "CRM Kurzform", "Website", "Geschlecht", "Vorname", "Nachname", "Position",
"Suchbegriffskategorie", "E-Mail-Adresse", "LinkedIn-Link", "Timestamp"]
contacts_sheet.update(values=[header], range_name="A1:K1")
# Optional: Alignment Demo hier nicht mehr aufrufen
# alignment_demo(contacts_sheet) # NICHT MEHR NÖTIG/FALSCH
debug_print("Neues Blatt 'Contacts' erstellt und Header eingetragen.")
# Positionen, nach denen gesucht wird
positions_to_search = ["Serviceleiter", "Leiter Kundendienst", "IT-Leiter", "Leiter IT", "Geschäftsführer", "Vorstand", "Disponent", "Einsatzleiter"]
# Gehe Zeilen im Hauptblatt durch
for i in range(start_row_index_in_sheet, len(all_data) + 1):
row_index_in_list = i - 1
row = all_data[row_index_in_list]
company_name = row[COLUMN_MAP["CRM Name"]] if len(row) > COLUMN_MAP["CRM Name"] else ""
crm_kurzform = row[COLUMN_MAP["CRM Kurzform"]] if len(row) > COLUMN_MAP["CRM Kurzform"] else ""
website = row[COLUMN_MAP["CRM Website"]] if len(row) > COLUMN_MAP["CRM Website"] else ""
if not all([company_name, crm_kurzform, website]):
debug_print(f"Zeile {i}: Übersprungen (fehlende CRM Daten: Name, Kurzform oder Website).")
continue
debug_print(f"Zeile {i}: Suche Kontakte für '{crm_kurzform}'...")
all_found_contacts = []
contact_counts = {pos: 0 for pos in ["Serviceleiter", "IT-Leiter", "Geschäftsführer", "Disponent"]} # Für die Zählung im Hauptblatt
for position in positions_to_search:
# Suche max. 5 Kontakte pro Position, um API Calls/Kosten zu begrenzen
found_contacts = search_linkedin_contacts(company_name, website, position, crm_kurzform, num_results=5)
# Zählung für das Hauptblatt (vereinfachte Kategorien)
if "serviceleiter" in position.lower() or "kundendienst" in position.lower() or "einsatzleiter" in position.lower():
contact_counts["Serviceleiter"] += len(found_contacts)
elif "it-leiter" in position.lower() or "leiter it" in position.lower():
contact_counts["IT-Leiter"] += len(found_contacts)
elif "geschäftsführer" in position.lower() or "vorstand" in position.lower():
contact_counts["Geschäftsführer"] += len(found_contacts)
elif "disponent" in position.lower():
contact_counts["Disponent"] += len(found_contacts)
# Füge gefundene Kontakte zur Liste hinzu (mit Suchkategorie)
for contact in found_contacts:
contact["Suchbegriffskategorie"] = position
all_found_contacts.append(contact)
time.sleep(1.5) # Kleine Pause zwischen SerpAPI-Aufrufen
# Verarbeite gefundene Kontakte und schreibe ins Contacts-Sheet
rows_to_append = []
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
unique_contacts = {c['LinkedInURL']: c for c in all_found_contacts}.values() # Deduplizieren basierend auf URL
for contact in unique_contacts:
firstname = contact.get("Vorname", "")
lastname = contact.get("Nachname", "")
gender_value = get_gender(firstname)
email = get_email_address(firstname, lastname, website)
contact_row = [
contact.get("Firmenname", ""),
contact.get("CRM Kurzform", ""),
contact.get("Website", ""),
gender_value,
firstname,
lastname,
contact.get("Position", ""),
contact.get("Suchbegriffskategorie", ""),
email,
contact.get("LinkedInURL", ""),
timestamp
]
rows_to_append.append(contact_row)
if rows_to_append:
try:
# Verwende append_rows für Effizienz
contacts_sheet.append_rows(rows_to_append, value_input_option='USER_ENTERED')
debug_print(f"Zeile {i}: {len(rows_to_append)} neue Kontakte zum 'Contacts'-Blatt hinzugefügt.")
except Exception as e:
debug_print(f"Zeile {i}: Fehler beim Hinzufügen von Kontakten zum Sheet: {e}")
# Evtl. einzeln versuchen bei Fehler?
# Aktualisiere Trefferzahlen und Timestamp im Hauptblatt (Batch Update)
main_sheet_updates = []
main_sheet_updates.append({'range': f'AI{i}', 'values': [[str(contact_counts["Serviceleiter"])]]})
main_sheet_updates.append({'range': f'AJ{i}', 'values': [[str(contact_counts["IT-Leiter"])]]})
main_sheet_updates.append({'range': f'AK{i}', 'values': [[str(contact_counts["Geschäftsführer"])]]})
main_sheet_updates.append({'range': f'AL{i}', 'values': [[str(contact_counts["Disponent"])]]})
main_sheet_updates.append({'range': f'AM{i}', 'values': [[timestamp]]}) # Contact Search Timestamp
sheet_handler.batch_update_cells(main_sheet_updates)
debug_print(f"Zeile {i}: Kontaktzahlen im Hauptblatt aktualisiert: {contact_counts} Timestamp in AM gesetzt.")
# Pause nach Verarbeitung einer Firma
time.sleep(Config.RETRY_DELAY)
debug_print("Contact Research abgeschlossen.")
# ==================== ALIGNMENT DEMO (Hauptblatt) ====================
# ==================== ALIGNMENT DEMO (Hauptblatt) ====================
# Diese Funktion ist bereits im Code vorhanden (Zeile ~1230 in der vorherigen Version)
# Sie bleibt unverändert:
def alignment_demo(sheet):
"""Schreibt die Header-Struktur (Zeilen 1-5, jetzt bis Spalte AX) ins angegebene Sheet."""
new_headers = [
[ # Spaltenname (Zeile 1)
"ReEval Flag", "CRM Name", "CRM Kurzform", "CRM Website", "CRM Ort", "CRM Beschreibung", "CRM Branche", "CRM Beschreibung Branche extern", "CRM Anzahl Techniker", "CRM Umsatz", "CRM Anzahl Mitarbeiter", "CRM Vorschlag Wiki URL", "Wiki URL", "Wiki Absatz", "Wiki Branche", "Wiki Umsatz", "Wiki Mitarbeiter", "Wiki Kategorien", "Chat Wiki Konsistenzprüfung", "Chat Begründung Wiki Inkonsistenz", "Chat Vorschlag Wiki Artikel", "Begründung bei Abweichung", "Chat Vorschlag Branche", "Chat Konsistenz Branche", "Chat Begründung Abweichung Branche", "Chat Prüfung FSM Relevanz", "Chat Begründung für FSM Relevanz", "Chat Schätzung Anzahl Mitarbeiter", "Chat Konsistenzprüfung Mitarbeiterzahl", "Chat Begründung Abweichung Mitarbeiterzahl", "Chat Einschätzung Anzahl Servicetechniker", "Chat Begründung Abweichung Anzahl Servicetechniker", "Chat Schätzung Umsatz", "Chat Begründung Abweichung Umsatz", "Linked Serviceleiter gefunden", "Linked It-Leiter gefunden", "Linked Management gefunden", "Linked Disponent gefunden", "Contact Search Timestamp", "Wikipedia Timestamp", "Timestamp letzte Prüfung", "Version", "Tokens", "Website Rohtext", "Website Zusammenfassung",
"Website Scrape Timestamp", # AT
"Geschätzter Techniker Bucket", # AU
"Finaler Umsatz (Wiki>CRM)", # AV
"Finaler Mitarbeiter (Wiki>CRM)", # AW
"Wiki Verif. Timestamp", # AX
"SerpAPI Wiki Search Timestamp" # AY (NEU)
],
[ # Quelle der Daten (Zeile 2)
"CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "LinkedIn (via SerpApi)", "LinkedIn (via SerpApi)", "LinkedIn (via SerpApi)", "LinkedIn (via SerpApi)", "System", "System", "System", "System", "System", "Web Scraper", "Chat GPT API",
"System", # AT
"ML Modell / Skript", # AU
"Skript (Wiki/CRM)", # AV
"Skript (Wiki/CRM)", # AW
"System", # AX
"System" # AY (NEU)
],
[ # Feldkategorie (Zeile 3)
"Prozess", "Firmenname", "Firmenname", "Website", "Ort", "Beschreibung (Text)", "Branche", "Branche", "Anzahl Servicetechniker", "Umsatz", "Anzahl Mitarbeiter", "Wikipedia Artikel URL", "Wikipedia Artikel", "Beschreibung (Text)", "Branche", "Umsatz", "Anzahl Mitarbeiter", "Kategorien (Text)", "Verifizierung", "Begründung bei Abweichung", "Wikipedia Artikel", "Wikipedia Artikel", "Branche", "Branche", "Branche", "FSM Relevanz", "FSM Relevanz", "Anzahl Mitarbeiter", "Anzahl Mitarbeiter", "Anzahl Mitarbeiter", "Anzahl Servicetechniker", "Anzahl Servicetechniker", "Umsatz", "Umsatz", "Kontakte zur Firma", "Kontakte zur Firma", "Kontakte zur Firma", "Kontakte zur Firma", "Timestamp", "Timestamp", "Timestamp", "Version des Skripts die verwendet wurde", "ChatGPT Tokens", "Website-Content", "Website Zusammenfassung",
"Timestamp", # AT
"Anzahl Servicetechniker Bucket", # AU
"Umsatz", # AV
"Anzahl Mitarbeiter", # AW
"Timestamp", # AX
"Timestamp" # AY (NEU)
],
[ # Kurze Beschreibung (Zeile 4)
"Systemspalte...", "Enthält den Firmennamen...", "Manuell gepflegte Kurzform...", "Website des Unternehmens.", "Ort des Unternehmens.", "Kurze Beschreibung...", "Aktuelle Branchenzuweisung...", "Externe Branchenbeschreibung...", "Recherchierte Anzahl...", "Umsatz in Mio. € (CRM).", "Anzahl Mitarbeiter (CRM).", "Vorgeschlagene Wikipedia URL...", "Wikipedia URL...", "Erster Absatz...", "Wikipedia-Branche...", "Wikipedia-Umsatz...", "Wikipedia-Mitarbeiterzahl...", "Liste der Wikipedia-Kategorien.", "\"OK\" oder \"X\" Ergebnis...", "Begründung bei Inkonsistenz...", "Chat-Vorschlag Wiki Artikel...", "Nicht genutzt...", "Branchenvorschlag via ChatGPT...", "Vergleich: Übereinstimmung CRM vs. ...", "Begründung bei abweichender...", "FSM-Relevanz: Bewertung...", "Begründung zur FSM-Bewertung.", "Schätzung Anzahl Mitarbeiter...", "Vergleich CRM vs. Wiki vs. ...", "Begründung bei Mitarbeiterabweichung...", "Schätzung Servicetechniker...", "Begründung bei Abweichung...", "Schätzung Umsatz via ChatGPT.", "Begründung bei Umsatzabweichung.", "Anzahl Kontakte (Serviceleiter)...", "Anzahl Kontakte (IT-Leiter)...", "Anzahl Kontakte (Management)...", "Anzahl Kontakte (Disponent)...", "Timestamp der Kontaktsuche.", "Timestamp der Wikipedia-Suche/Extraktion.", "Timestamp der ChatGPT-Bewertung / Letzte Prüfung der Zeile.", "Ausgabe der Skriptversion...", "Token-Zählung...", "Roh extrahierter Text...", "Zusammenfassung des Webseiteninhalts...",
"Timestamp des letzten Website-Scrapings (AR, AS).", # AT
"Geschätzter Bucket (1-7) für Servicetechniker...", # AU
"Konsolidierter Umsatz (Mio €) nach Priorität Wiki > CRM.", # AV
"Konsolidierte Mitarbeiterzahl nach Priorität Wiki > CRM.", # AW
"Timestamp der letzten Wiki-Verifikation (Spalten S-Y).", # AX
"Timestamp der letzten SerpAPI-Suche nach fehlender Wiki-URL (Modus find_wiki_serp)." # AY (NEU)
],
[ # Aufgabe / Funktion (Zeile 5) - Ergänzt um AX
"Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Wird durch Wikipedia Scraper bereitgestellt", "Wird zunächst nicht verwendet...", "Wird u.a. zur finalen Ermittlung...", "Wird u.a. mit CRM-Umsatz...", "Wird u.a. mit CRM-Anzahl...", "Wenn Website-Daten fehlen...", "\"Es soll durch ChatGPT geprüft werden...", "\"Liegt eine Inkonsistenz...", "\"Sollte durch die Wikipedia-Suche...", "XXX derzeit nicht verwendet...", "\"ChatGPT soll anhand der vorliegenden...", "Die in Spalte CRM festgelegte...", "Weicht die von ChatGPT ermittelte...", "ChatGPT soll anhand der vorliegenden Daten prüfen...", "Die in 'Chat Begründung für FSM Relevanz'...", "Nur wenn kein Wikipedia-Eintrag...", "Entspricht die durch ChatGPT ermittelte...", "Weicht die von ChatGPT geschätzte...", "ChatGPT soll auf Basis öffentlich...", "Weicht die von ChatGPT geschätzte...", "Nur wenn kein Wikipedia-Eintrag...", "ChatGPT soll signifikante Umsatzabweichungen...", "Über SerpAPI wird zusammen...", "Über SerpAPI wird zusammen...", "Über SerpAPI wird zusammen...", "Über SerpAPI wird zusammen...", "Wenn die Kontaktsuche gestartet wird...", "Wenn die Wikipedia-Suche gestartet wird...", "Wenn die ChatGPT-Bewertung gestartet wird...", "Wird durch das System befüllt", "Wird durch tiktoken berechnet", "Wird durch Web Scraper...", "Wird durch ChatGPT API...",
"Timestamp wird gesetzt, wenn Website Rohtext/Zusammenfassung geschrieben werden.", # AT
"Ergebnis der Schätzung durch das trainierte ML-Modell.", # AU
"Vom Skript berechneter Wert, priorisiert Wiki > CRM...", # AV
"Vom Skript berechneter Wert, priorisiert Wiki > CRM...", # AW
"Timestamp wird gesetzt, wenn Wiki-Verifikation (S-Y) durchgeführt wurde.", # AX
"Timestamp wird gesetzt, nachdem versucht wurde, eine fehlende Wiki-URL via SerpAPI zu finden." # AY (NEU)
]
]
num_cols = len(new_headers[0])
# ... (Rest der Funktion zum Schreiben der Header bleibt gleich, verwendet jetzt AY als Endspalte) ...
def colnum_string(n): # Hilfsfunktion bleibt
string = ""
while n > 0: n, remainder = divmod(n - 1, 26); string = chr(65 + remainder) + string
return string
end_col_letter = colnum_string(num_cols)
header_range = f"A1:{end_col_letter}{len(new_headers)}"
try:
sheet.update(values=new_headers, range_name=header_range)
logging.info(f"Alignment-Demo abgeschlossen: Header in Bereich {header_range} geschrieben.")
except Exception as e:
logging.error(f"FEHLER beim Schreiben der Alignment-Demo Header: {e}")
# ==================== DATA PROCESSOR ====================
class DataProcessor:
"""
Verarbeitet Daten aus dem Google Sheet, führt verschiedene Anreicherungs-
und Analyseprozesse durch, inklusive Timestamp-basierter Überspringung
und erzwungener Neuverarbeitung im Re-Eval-Modus.
Enthält auch die Datenvorbereitung für das ML-Modell.
"""
def __init__(self, sheet_handler):
"""
Initialisiert den DataProcessor.
Args:
sheet_handler (GoogleSheetHandler): Eine initialisierte Instanz des GoogleSheetHandlers.
"""
self.sheet_handler = sheet_handler
# Erstelle eine Instanz des Scrapers für diesen Prozessor
# Annahme: WikipediaScraper ist importiert
self.wiki_scraper = WikipediaScraper()
logging.info("DataProcessor initialisiert.")
# Die zentrale Methode zur Verarbeitung einer einzelnen Zeile
# @retry_on_failure # Retry auf der gesamten Zeile ist riskant
def _process_single_row(self, row_num_in_sheet, row_data,
process_wiki=True, process_chatgpt=True, process_website=True,
force_reeval=False): # <-- Neuer Parameter
"""
Verarbeitet die Daten für eine einzelne Zeile.
Priorisiert Wiki-Artikelsuche/-Validierung VOR Extraktion.
Prüft Timestamps, es sei denn force_reeval=True.
"""
logging.info(f"--- Starte Verarbeitung für Zeile {row_num_in_sheet} {'(Re-Eval)' if force_reeval else ''} ---")
updates = []
now_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
any_processing_done = False
wiki_data_updated_in_this_run = False
# Hilfsfunktion für sicheren Zellenzugriff
def get_cell_value(key):
# Annahme: COLUMN_MAP ist global verfügbar
idx = COLUMN_MAP.get(key)
if idx is not None and len(row_data) > idx:
return row_data[idx]
return ""
# Lese initiale Werte
company_name = get_cell_value("CRM Name")
website_url = get_cell_value("CRM Website"); original_website = website_url
crm_branche = get_cell_value("CRM Branche"); crm_beschreibung = get_cell_value("CRM Beschreibung")
konsistenz_s = get_cell_value("Chat Wiki Konsistenzprüfung")
website_raw = get_cell_value("Website Rohtext") or "k.A."
website_summary = get_cell_value("Website Zusammenfassung") or "k.A."
final_wiki_data = {
'url': get_cell_value("Wiki URL") or 'k.A.',
'first_paragraph': get_cell_value("Wiki Absatz") or 'k.A.',
'branche': get_cell_value("Wiki Branche") or 'k.A.',
'umsatz': get_cell_value("Wiki Umsatz") or 'k.A.',
'mitarbeiter': get_cell_value("Wiki Mitarbeiter") or 'k.A.',
'categories': get_cell_value("Wiki Kategorien") or 'k.A.'
}
final_page_object = None
# --- 1. Website Handling (Prüft AT oder force_reeval) ---
website_ts_missing = not get_cell_value("Website Scrape Timestamp").strip()
website_processing_needed = process_website and (force_reeval or website_ts_missing)
if website_processing_needed:
any_processing_done = True
logging.info(f"Zeile {row_num_in_sheet}: Starte Website Verarbeitung (Grund: {'Re-Eval' if force_reeval else 'AT fehlt'})...")
if not website_url or website_url.strip().lower() == "k.a.":
logging.debug(" -> Suche Website via SERP...")
# Annahme: serp_website_lookup existiert und nutzt logging
new_website = serp_website_lookup(company_name)
if new_website != "k.A.":
website_url = new_website
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["CRM Website"] + 1)}{row_num_in_sheet}', 'values': [[website_url]]})
if website_url and website_url.strip().lower() != "k.a.":
logging.debug(f" -> Scrape Rohtext von {website_url}...")
# Annahme: get_website_raw existiert und nutzt logging
new_website_raw = get_website_raw(website_url)
logging.debug(f" -> Fasse Rohtext zusammen (Länge: {len(str(new_website_raw))})...") # str() für Sicherheit
# Annahme: summarize_website_content existiert und nutzt logging
new_website_summary = summarize_website_content(new_website_raw)
website_raw = new_website_raw
website_summary = new_website_summary
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Rohtext"] + 1)}{row_num_in_sheet}', 'values': [[website_raw]]})
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Zusammenfassung"] + 1)}{row_num_in_sheet}', 'values': [[website_summary]]})
else:
logging.warning(f" -> Keine gültige Website gefunden/vorhanden für {company_name}.")
website_raw, website_summary = "k.A.", "k.A."
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Rohtext"] + 1)}{row_num_in_sheet}', 'values': [['k.A.']]})
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Zusammenfassung"] + 1)}{row_num_in_sheet}', 'values': [['k.A.']]})
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Scrape Timestamp"] + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]})
elif process_website:
logging.debug(f"Zeile {row_num_in_sheet}: Überspringe Website (AT vorhanden und kein Re-Eval).")
# --- 2. Wikipedia Artikel Findung/Validierung (Prüft AN, S='X(Copied)' oder force_reeval) ---
wiki_ts_an_missing = not get_cell_value("Wikipedia Timestamp").strip()
status_s_indicates_reparse = konsistenz_s.strip().upper() == "X (URL COPIED)"
wiki_processing_needed = process_wiki and (force_reeval or wiki_ts_an_missing or status_s_indicates_reparse)
url_to_potentially_parse = get_cell_value("Wiki URL").strip()
if wiki_processing_needed:
any_processing_done = True
logging.info(f"Zeile {row_num_in_sheet}: Starte Wikipedia Artikel Findung/Validierung (Grund: {'Re-Eval' if force_reeval else f'AN fehlt? {wiki_ts_an_missing}, S=X(Copied)? {status_s_indicates_reparse}'})...")
validated_page = None
# Prüfe zuerst, ob die URL in M direkt valide ist
if url_to_potentially_parse and url_to_potentially_parse.lower() not in ["k.a.", "kein artikel gefunden"] and url_to_potentially_parse.lower().startswith("http"):
logging.debug(f" -> Prüfe Validität der vorhandenen URL aus Spalte M: {url_to_potentially_parse}")
try:
# Verwende die wiki_scraper Instanz der Klasse
page_from_m = wikipedia.page(url_to_potentially_parse.split('/wiki/')[-1].replace('_', ' '), auto_suggest=False, preload=True)
if self.wiki_scraper._validate_article(page_from_m, company_name, website_url): # self. hinzufügen
validated_page = page_from_m
logging.info(f" -> Vorhandene URL aus M '{validated_page.url}' ist valide.")
else:
logging.debug(f" -> Vorhandene URL aus M '{page_from_m.title}' ist NICHT valide.")
except wikipedia.exceptions.PageError:
logging.warning(f" -> Seite für vorhandene URL aus M '{url_to_potentially_parse}' nicht gefunden (PageError).")
except wikipedia.exceptions.DisambiguationError as e_disamb_m:
logging.info(f" -> Vorhandene URL aus M '{url_to_potentially_parse}' ist eine Begriffsklärung. Starte Suche...")
# Verwende die wiki_scraper Instanz der Klasse
validated_page = self.wiki_scraper.search_company_article(company_name, website_url) # self. hinzufügen
except Exception as e_val_m:
logging.error(f" -> Fehler beim Prüfen der URL aus M '{url_to_potentially_parse}': {e_val_m}")
# Wenn URL aus M nicht valide war oder keine vorhanden war, starte die Suche
if not validated_page:
logging.info(f" -> Keine valide URL in M gefunden oder Prüfung fehlgeschlagen. Starte Wikipedia-Suche für '{company_name}'...")
# Verwende die wiki_scraper Instanz der Klasse
validated_page = self.wiki_scraper.search_company_article(company_name, website_url) # self. hinzufügen
# Datenextraktion NACH erfolgreicher Findung/Validierung
if validated_page:
logging.info(f" -> Valider Artikel gefunden/bestätigt: {validated_page.url}. Extrahiere Daten...")
final_page_object = validated_page
# Verwende die wiki_scraper Instanz der Klasse
extracted_data = self.wiki_scraper.extract_company_data(validated_page.url) # self. hinzufügen
final_wiki_data = extracted_data
wiki_data_updated_in_this_run = True
logging.info(f" -> Datenextraktion für '{validated_page.title}' abgeschlossen.")
else:
logging.warning(f" -> Konnte keinen validen Wikipedia Artikel für '{company_name}' finden/bestätigen.")
final_wiki_data = {'url': 'Kein Artikel gefunden', 'first_paragraph': 'k.A.', 'branche': 'k.A.', 'umsatz': 'k.A.', 'mitarbeiter': 'k.A.', 'categories': 'k.A.'}
wiki_data_updated_in_this_run = True
# Füge Updates für M-R und AN hinzu
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki URL"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('url', 'k.A.')]]})
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Absatz"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('first_paragraph', 'k.A.')]]})
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Branche"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('branche', 'k.A.')]]})
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Umsatz"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('umsatz', 'k.A.')]]})
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Mitarbeiter"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('mitarbeiter', 'k.A.')]]})
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Kategorien"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('categories', 'k.A.')]]})
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wikipedia Timestamp"] + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]})
# Setze S zurück, wenn nötig
if status_s_indicates_reparse or (url_to_potentially_parse != final_wiki_data.get('url')) or force_reeval:
s_idx = COLUMN_MAP.get("Chat Wiki Konsistenzprüfung")
if s_idx is not None:
s_let = self.sheet_handler._get_col_letter(s_idx + 1)
updates.append({'range': f'{s_let}{row_num_in_sheet}', 'values': [["?"]]})
logging.info(f" -> Status S zurückgesetzt auf '?' für erneute Verifikation.")
elif process_wiki:
logging.debug(f"Zeile {row_num_in_sheet}: Überspringe Wikipedia Verarbeitung (AN vorhanden, kein S=X(Copied) und kein Re-Eval).")
# --- 3. ChatGPT Evaluationen (Branch etc.) ---
chat_ts_ao_missing = not get_cell_value("Timestamp letzte Prüfung").strip()
run_chat_eval = process_chatgpt and (force_reeval or chat_ts_ao_missing or wiki_data_updated_in_this_run)
if run_chat_eval:
logging.info(f"Zeile {row_num_in_sheet}: Starte ChatGPT Evaluationen (Grund: {'Re-Eval' if force_reeval else f'AO fehlt? {chat_ts_ao_missing}, Wiki gerade aktualisiert? {wiki_data_updated_in_this_run}'})...")
any_processing_done = True
# Annahme: evaluate_branche_chatgpt existiert und nutzt logging
branch_result = evaluate_branche_chatgpt(
crm_branche, crm_beschreibung,
final_wiki_data.get('branche', 'k.A.'),
final_wiki_data.get('categories', 'k.A.'),
website_summary
)
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Vorschlag Branche"] + 1)}{row_num_in_sheet}', 'values': [[branch_result.get('branch', 'Fehler')]]})
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Konsistenz Branche"] + 1)}{row_num_in_sheet}', 'values': [[branch_result.get('consistency', 'Fehler')]]})
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begründung Abweichung Branche"] + 1)}{row_num_in_sheet}', 'values': [[branch_result.get('justification', 'Fehler')]]})
# --- Hier Platz für weitere ChatGPT-Calls ---
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Timestamp letzte Prüfung"] + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]})
elif process_chatgpt:
logging.debug(f"Zeile {row_num_in_sheet}: Überspringe ChatGPT Evaluationen (AO vorhanden, Wiki nicht gerade aktualisiert und kein Re-Eval).")
# --- 4. Abschließende Updates ---
if any_processing_done:
# Annahme: Config ist verfügbar
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Version"] + 1)}{row_num_in_sheet}', 'values': [[Config.VERSION]]})
# --- 5. Batch Update für diese Zeile ---
if updates:
logging.info(f"Zeile {row_num_in_sheet}: Sende Batch-Update mit {len(updates)} Operationen...")
success = self.sheet_handler.batch_update_cells(updates) # Annahme: nutzt logging
if not success: logging.error(f"Zeile {row_num_in_sheet}: FEHLER beim Batch-Update.")
else:
if not any_processing_done:
logging.info(f"Zeile {row_num_in_sheet}: Keine Updates zum Schreiben (alle Schritte übersprungen).")
logging.info(f"--- Verarbeitung für Zeile {row_num_in_sheet} abgeschlossen ---")
# Annahme: Config ist verfügbar
time.sleep(max(0.1, getattr(Config, 'RETRY_DELAY', 5) / 20))
# Methode zur sequenziellen Verarbeitung (ruft _process_single_row ohne force_reeval)
def process_rows_sequentially(self, start_data_index, num_rows_to_process,
process_wiki=True, process_chatgpt=True, process_website=True):
""" Verarbeitet Zeilen sequentiell ab einem Startindex. """
# Annahme: sheet_handler wurde im __init__ gesetzt
if not self.sheet_handler or not self.sheet_handler.sheet_values:
logging.error("Sheet Handler nicht verfügbar oder keine Daten geladen in process_rows_sequentially.")
return
data_rows = self.sheet_handler.get_data() # Daten ohne Header
header_rows = 5 # Annahme
if start_data_index >= len(data_rows):
logging.warning(f"Startindex {start_data_index} liegt hinter der letzten Datenzeile ({len(data_rows)}). Keine Verarbeitung.")
return
end_row_index = min(start_data_index + num_rows_to_process, len(data_rows))
actual_rows_to_process = end_row_index - start_data_index
if actual_rows_to_process <= 0:
logging.info("Keine Zeilen zur sequenziellen Verarbeitung übrig.")
return
logging.info(f"Verarbeite {actual_rows_to_process} Zeilen sequenziell (Daten-Index {start_data_index} bis {end_row_index - 1})...")
for i in range(start_data_index, end_row_index):
if i >= len(data_rows):
logging.warning(f"WARNUNG: Index {i} überschreitet Datenlänge ({len(data_rows)}). Breche Schleife ab.")
break
row_data = data_rows[i]
row_num_in_sheet = i + header_rows + 1
# Rufe die zentrale Verarbeitungsmethode auf, OHNE force_reeval
try:
self._process_single_row(row_num_in_sheet, row_data,
process_wiki, process_chatgpt, process_website,
force_reeval=False) # HIER ist der Unterschied zu reeval
except Exception as e_seq:
# Fange Fehler bei der Verarbeitung einzelner Zeilen ab, um den Lauf nicht zu stoppen
logging.exception(f"Fehler bei der sequenziellen Verarbeitung von Zeile {row_num_in_sheet}: {e_seq}")
# Optional: Markiere die Zeile im Sheet als fehlerhaft?
logging.info(f"Sequenzielle Verarbeitung von {actual_rows_to_process} Zeilen abgeschlossen.")
# Methode für den Re-Eval Modus (ruft _process_single_row MIT force_reeval)
def process_reevaluation_rows(self, row_limit=None, clear_flag=True):
"""
Verarbeitet nur Zeilen, die in Spalte A mit 'x' markiert sind.
Ruft _process_single_row für jede dieser Zeilen auf mit force_reeval=True.
Verarbeitet maximal row_limit Zeilen.
Löscht optional das 'x'-Flag nach erfolgreicher Verarbeitung.
"""
logging.info(f"Starte Re-Evaluierungsmodus (Spalte A = 'x'). Max. Zeilen: {row_limit if row_limit is not None else 'Unbegrenzt'}")
if not self.sheet_handler.load_data(): return
all_data = self.sheet_handler.get_all_data_with_headers()
if not all_data or len(all_data) <= 5:
logging.warning("Keine Daten für Re-Evaluation gefunden.")
return
header_rows = 5
data_rows = all_data[header_rows:]
# Annahme: COLUMN_MAP ist global verfügbar
reeval_col_idx = COLUMN_MAP.get("ReEval Flag")
if reeval_col_idx is None:
logging.error("FEHLER: 'ReEval Flag' Spaltenindex nicht in COLUMN_MAP gefunden.")
return
rows_to_process = []
for idx, row in enumerate(data_rows):
if len(row) > reeval_col_idx and row[reeval_col_idx].strip().lower() == "x":
row_num_in_sheet = idx + header_rows + 1
rows_to_process.append({'row_num': row_num_in_sheet, 'data': row})
found_count = len(rows_to_process)
logging.info(f"{found_count} Zeilen mit ReEval-Flag 'x' gefunden.")
if found_count == 0:
logging.info("Keine Zeilen zur Re-Evaluation markiert.")
return
processed_count = 0
updates_clear_flag = []
rows_actually_processed = []
for task in rows_to_process:
if row_limit is not None and processed_count >= row_limit:
logging.info(f"Zeilenlimit ({row_limit}) für Re-Evaluation erreicht. Breche weitere Verarbeitung ab.")
break
row_num = task['row_num']
row_data = task['data']
try:
# Rufe _process_single_row mit force_reeval=True auf
self._process_single_row(row_num, row_data,
process_wiki=True, process_chatgpt=True, process_website=True,
force_reeval=True) # WICHTIG!
processed_count += 1
rows_actually_processed.append(row_num)
if clear_flag:
flag_col_letter = self.sheet_handler._get_col_letter(reeval_col_idx + 1)
if flag_col_letter:
updates_clear_flag.append({'range': f'{flag_col_letter}{row_num}', 'values': [['']]})
except Exception as e_proc:
logging.exception(f"FEHLER bei Re-Evaluation von Zeile {row_num}: {e_proc}")
if clear_flag and updates_clear_flag:
logging.info(f"Lösche ReEval-Flags für {len(updates_clear_flag)} erfolgreich verarbeitete Zeilen ({rows_actually_processed})...")
success = self.sheet_handler.batch_update_cells(updates_clear_flag)
if not success:
logging.error("FEHLER beim Löschen der ReEval-Flags.")
logging.info(f"Re-Evaluierung abgeschlossen. {processed_count} Zeilen verarbeitet (Limit war: {row_limit}, Gefunden: {found_count}).")
# Methode für SERP API Website Lookup
def process_serp_website_lookup_for_empty(self):
""" Neuer Modus 22: Füllt fehlende Websites via SERP API. """
logging.info("Starte Modus: SERP API Website Lookup für leere Zellen in Spalte D.")
# Annahme: sheet_handler wurde initialisiert
if not self.sheet_handler.load_data(): return
data_rows = self.sheet_handler.get_data()
header_rows = 5
rows_processed = 0
try:
website_col_idx = COLUMN_MAP["CRM Website"]
name_col_idx = COLUMN_MAP["CRM Name"]
website_col_letter = self.sheet_handler._get_col_letter(website_col_idx + 1)
except KeyError as e:
logging.critical(f"FEHLER: Benötigte Spalte '{e}' für Modus nicht in COLUMN_MAP.")
return
except Exception as e:
logging.critical(f"FEHLER beim Holen der Spaltenbuchstaben: {e}")
return
updates = []
for i, row in enumerate(data_rows):
row_num_in_sheet = i + header_rows + 1
current_website = row[website_col_idx] if len(row) > website_col_idx else ""
if not current_website or current_website.strip().lower() == "k.a.":
company_name = row[name_col_idx] if len(row) > name_col_idx else ""
if not company_name:
logging.warning(f"Zeile {row_num_in_sheet}: Übersprungen (kein Firmenname für Lookup).")
continue
logging.info(f"Zeile {row_num_in_sheet}: Suche Website für '{company_name}'...")
# Annahme: serp_website_lookup existiert und nutzt logging
new_website = serp_website_lookup(company_name)
time.sleep(getattr(Config, 'RETRY_DELAY', 5) * 0.3) # Kurze Pause
if new_website != "k.A.":
updates.append({'range': f'{website_col_letter}{row_num_in_sheet}', 'values': [[new_website]]})
logging.info(f"Zeile {row_num_in_sheet}: Neue Website '{new_website}' gefunden und zum Update hinzugefügt.")
rows_processed += 1
else:
logging.info(f"Zeile {row_num_in_sheet}: Keine Website gefunden.")
# Optional: Limit für diesen Modus?
# if row_limit is not None and rows_processed >= row_limit: break
if updates:
logging.info(f"Sende Batch-Update für {rows_processed} gefundene Websites...")
self.sheet_handler.batch_update_cells(updates)
else:
logging.info("Keine fehlenden Websites gefunden zum Aktualisieren.")
logging.info(f"Modus 'website_lookup' abgeschlossen. {rows_processed} Websites ergänzt.")
# Methode für experimentelle Website Details
def process_website_details_for_marked_rows(self):
""" Neuer Modus 23 (EXPERIMENTELL): Extrahiert Website-Details für markierte Zeilen. """
logging.info("Starte Modus (EXPERIMENTELL): Website Detail Extraction für Zeilen mit 'x' in Spalte A.")
if not self.sheet_handler.load_data(): return
data_rows = self.sheet_handler.get_data()
header_rows = 5
rows_processed = 0
try:
reeval_col_idx = COLUMN_MAP["ReEval Flag"]
website_col_idx = COLUMN_MAP["CRM Website"]
details_col_idx = COLUMN_MAP["Website Rohtext"] # Nutze AR für Details? Besser neue Spalte!
details_col_letter = self.sheet_handler._get_col_letter(details_col_idx + 1)
# at_col_letter = self.sheet_handler._get_col_letter(COLUMN_MAP["Website Scrape Timestamp"] + 1) # Für Timestamp
except KeyError as e:
logging.critical(f"FEHLER: Benötigte Spalte '{e}' für Modus nicht in COLUMN_MAP.")
return
except Exception as e:
logging.critical(f"FEHLER beim Holen der Spaltenbuchstaben: {e}")
return
updates = []
for i, row in enumerate(data_rows):
row_num_in_sheet = i + header_rows + 1
if len(row) > reeval_col_idx and row[reeval_col_idx].strip().lower() == "x":
website_url = row[website_col_idx] if len(row) > website_col_idx else ""
if not website_url or website_url.strip().lower() == "k.a.":
logging.warning(f"Zeile {row_num_in_sheet}: Keine gültige Website in Spalte D vorhanden, überspringe.")
continue
logging.info(f"Zeile {row_num_in_sheet}: Extrahiere Website Details von {website_url}...")
# Annahme: Funktion scrape_website_details existiert
try:
details = scrape_website_details(website_url)
except NameError:
logging.error("Funktion 'scrape_website_details' ist nicht definiert!")
details = "FEHLER: Funktion nicht definiert"
except Exception as e_detail:
logging.exception(f"Fehler bei scrape_website_details für {website_url}: {e_detail}")
details = f"FEHLER: {e_detail}"
updates.append({'range': f'{details_col_letter}{row_num_in_sheet}', 'values': [[str(details)]]}) # In AR schreiben
# Optional: Timestamp in AT setzen
# updates.append({'range': f'{at_col_letter}{row_num_in_sheet}', 'values': [[datetime.now().strftime("%Y-%m-%d %H:%M:%S")]]})
rows_processed += 1
time.sleep(getattr(Config, 'RETRY_DELAY', 5) * 0.2) # Kleine Pause
if updates:
logging.info(f"Sende Batch-Update für {rows_processed} Detail-Extraktionen...")
self.sheet_handler.batch_update_cells(updates)
else:
logging.info("Keine Zeilen mit 'x' gefunden für Detail-Extraktion.")
logging.info(f"Modus 'website_details' abgeschlossen. {rows_processed} Zeilen verarbeitet.")
# Methode zur Datenvorbereitung für ML
def prepare_data_for_modeling(self):
"""
Lädt Daten aus dem Google Sheet über den sheet_handler,
bereitet sie für das Decision Tree Modell vor.
"""
logging.info("Starte Datenvorbereitung für Modellierung...")
# Annahme: sheet_handler ist initialisiert und hat Daten geladen
if not self.sheet_handler or not self.sheet_handler.sheet_values:
logging.error("Fehler: Sheet Handler nicht initialisiert oder keine Daten geladen für prepare_data.")
if not self.sheet_handler.load_data(): # Versuch nachzuladen
logging.critical("Konnte Daten auch nach erneutem Versuch nicht laden.")
return None
all_data = self.sheet_handler.sheet_values # Verwende die Daten aus dem Handler
if len(all_data) <= 5:
logging.error("Fehler: Nicht genügend Datenzeilen im Sheet gefunden für Modellierung.")
return None
try: # Fange Fehler beim Zugriff auf Header ab
headers = all_data[0]
except IndexError:
logging.critical("FEHLER: Sheet scheint leer zu sein, keine Header gefunden.")
return None
data_rows = all_data[5:]
df = pd.DataFrame(data_rows, columns=headers)
logging.info(f"DataFrame für Modellierung erstellt mit {len(df)} Zeilen und {len(df.columns)} Spalten.")
# Notwendige Schlüssel aus COLUMN_MAP holen
try:
col_keys = {
"name": "CRM Name", "branche": "CRM Branche", "umsatz_crm": "CRM Umsatz",
"umsatz_wiki": "Wiki Umsatz", "ma_crm": "CRM Anzahl Mitarbeiter",
"ma_wiki": "Wiki Mitarbeiter", "techniker": "CRM Anzahl Techniker" # ANPASSEN WENN NÖTIG
}
col_indices = {key: COLUMN_MAP[val] for key, val in col_keys.items()}
# Erstelle Liste der Spaltennamen basierend auf den Headern im Sheet
cols_to_select = [headers[idx] for idx in col_indices.values()]
# Mapping von echtem Header zu internem Namen
rename_map = {headers[idx]: key for key, idx in col_indices.items()}
except KeyError as e:
logging.critical(f"FEHLER: Konnte Mapping für Schlüssel '{e}' in COLUMN_MAP nicht finden.")
return None
except IndexError as e:
logging.critical(f"FEHLER: Spaltenindex aus COLUMN_MAP ({e}) außerhalb der Grenzen der Header-Zeile ({len(headers)} Spalten). Prüfe COLUMN_MAP!")
return None
try: # Fange Fehler beim Auswählen der Spalten ab
df_subset = df[cols_to_select].copy()
df_subset.rename(columns=rename_map, inplace=True)
except KeyError as e:
logging.critical(f"FEHLER beim Auswählen/Umbenennen der Spalten: {e}. Verfügbare Spalten: {list(df.columns)}")
return None
logging.info(f"Benötigte Spalten für Modellierung ausgewählt und umbenannt: {list(df_subset.columns)}")
# --- Konsolidierung (wie in vorheriger Antwort, mit Logging) ---
def get_valid_numeric(value_str):
if value_str is None or pd.isna(value_str) or str(value_str).strip() == '': return np.nan
original_value = value_str
try:
cleaned_str = str(value_str).replace('.', '').replace("'", "").replace(',', '.') # Auch Apostroph entfernen
val = float(cleaned_str)
return val if val > 0 else np.nan
except (ValueError, TypeError):
logging.debug(f"Konntze Wert '{original_value}' nicht direkt in Float umwandeln.")
cleaned_str = re.sub(r'[^\d.]', '', str(value_str))
if not cleaned_str: return np.nan
try:
val = float(cleaned_str)
return val if val > 0 else np.nan
except ValueError:
logging.debug(f"Konntze auch bereinigten String '{cleaned_str}' aus '{original_value}' nicht umwandeln.")
return np.nan
cols_to_process = {
'Umsatz': ('umsatz_wiki', 'umsatz_crm', 'Finaler_Umsatz'),
'Mitarbeiter': ('ma_wiki', 'ma_crm', 'Finaler_Mitarbeiter')
}
for base_name, (wiki_col, crm_col, final_col) in cols_to_process.items():
logging.info(f"Verarbeite und konsolidiere '{base_name}' (Priorität: Wiki > CRM)...")
if wiki_col not in df_subset.columns: df_subset[wiki_col] = np.nan
if crm_col not in df_subset.columns: df_subset[crm_col] = np.nan
wiki_numeric = df_subset[wiki_col].apply(get_valid_numeric)
crm_numeric = df_subset[crm_col].apply(get_valid_numeric)
df_subset[final_col] = np.where(wiki_numeric.notna(), wiki_numeric, crm_numeric) # Vereinfacht: Wiki > CRM, sonst NaN
logging.info(f" -> {df_subset[final_col].notna().sum()} gültige '{final_col}' Werte erstellt.")
# --- Zielvariable vorbereiten (wie in vorheriger Antwort, mit Logging) ---
techniker_col = "techniker"
logging.info(f"Verarbeite Zielvariable '{techniker_col}'...")
df_subset['Anzahl_Servicetechniker_Numeric'] = pd.to_numeric(df_subset[techniker_col], errors='coerce')
initial_rows = len(df_subset)
df_filtered = df_subset[df_subset['Anzahl_Servicetechniker_Numeric'].notna() & (df_subset['Anzahl_Servicetechniker_Numeric'] > 0)].copy()
filtered_rows = len(df_filtered)
removed_rows = initial_rows - filtered_rows
if removed_rows > 0: logging.info(f"{removed_rows} Zeilen entfernt (fehlende/ungültige Technikerzahl).")
logging.info(f"Verbleibende Zeilen für Modellierung: {filtered_rows}")
if filtered_rows == 0: logging.error("FEHLER: Keine Zeilen mit gültiger Technikerzahl übrig!"); return None
# --- Techniker-Buckets erstellen (wie gehabt) ---
bins = [-1, 0, 19, 49, 99, 249, 499, float('inf')]
labels = ['Bucket_1_(0)', 'Bucket_2_(<20)', 'Bucket_3_(<50)', 'Bucket_4_(<100)', 'Bucket_5_(<250)', 'Bucket_6_(<500)', 'Bucket_7_(>499)']
df_filtered['Techniker_Bucket'] = pd.cut(df_filtered['Anzahl_Servicetechniker_Numeric'], bins=bins, labels=labels, right=True)
logging.info("Techniker-Buckets erstellt.")
logging.debug(f"Verteilung der Buckets:\n{df_filtered['Techniker_Bucket'].value_counts(normalize=True).round(3)}")
# --- Kategoriale Features (Branche) (wie gehabt) ---
branche_col = "branche"
logging.info(f"Verarbeite kategoriales Feature '{branche_col}' für One-Hot Encoding...")
df_filtered[branche_col] = df_filtered[branche_col].astype(str).fillna('Unbekannt').str.strip()
df_encoded = pd.get_dummies(df_filtered, columns=[branche_col], prefix='Branche', dummy_na=False)
logging.info(f"One-Hot Encoding für '{branche_col}' durchgeführt.")
# --- Finale Auswahl (wie gehabt) ---
feature_columns = [col for col in df_encoded.columns if col.startswith('Branche_')]
feature_columns.extend(['Finaler_Umsatz', 'Finaler_Mitarbeiter'])
target_column = 'Techniker_Bucket'
original_data_cols = ['name', 'Anzahl_Servicetechniker_Numeric'] # 'name' statt 'CRM Name' intern
df_model_ready = df_encoded[original_data_cols + feature_columns + [target_column]].copy()
for col in ['Finaler_Umsatz', 'Finaler_Mitarbeiter']:
df_model_ready[col] = pd.to_numeric(df_model_ready[col], errors='coerce')
df_model_ready = df_model_ready.reset_index(drop=True)
logging.info("Datenvorbereitung für Modellierung abgeschlossen.")
nan_counts = df_model_ready[['Finaler_Umsatz', 'Finaler_Mitarbeiter']].isna().sum()
logging.info(f"Fehlende Werte in numerischen Features vor Imputation:\n{nan_counts}")
return df_model_ready
# ==================== MAIN FUNCTION ====================
# ==================== MAIN FUNCTION ====================
def main():
# WICHTIG: Global LOG_FILE wird benötigt, aber erst nach Arg-Parsing gesetzt.
global LOG_FILE
# --- Logging Setup (Konfiguration von Level und Format) ---
# Diese Konfiguration wird wirksam, sobald die Handler hinzugefügt werden.
import logging
log_level = logging.DEBUG # Explizit DEBUG setzen für detaillierte Logs
log_format = '%(asctime)s - %(levelname)-8s - %(name)-15s - %(message)s' # Angepasstes Format
# Root-Logger konfigurieren (noch ohne File Handler)
logging.basicConfig(level=log_level, format=log_format, handlers=[]) # WICHTIG: handlers=[] verhindert default Console Handler
# Console Handler explizit hinzufügen
console_handler = logging.StreamHandler()
console_handler.setLevel(log_level) # Nimm das globale Level
console_handler.setFormatter(logging.Formatter(log_format))
logging.getLogger('').addHandler(console_handler) # Füge zum Root-Logger hinzu
# --- Ende Initial Logging Setup ---
# Testnachricht (geht nur an Konsole, da File Handler noch fehlt)
logging.debug("DEBUG Logging initial konfiguriert (nur Konsole).")
logging.info("INFO Logging initial konfiguriert (nur Konsole).")
# --- Initialisierung (Argument Parser etc.) ---
parser = argparse.ArgumentParser(description="Firmen-Datenanreicherungs-Skript v1.6.5") # Version aktualisiert
valid_modes = ["combined", "wiki", "website", "branch", "summarize", "reeval",
"website_lookup", "website_details", "contacts", "full_run",
"alignment", "train_technician_model", "update_wiki",
"find_wiki_serp"] # <-- NEUER MODUS HINZUGEFÜGT
parser.add_argument("--mode", type=str, help=f"Betriebsmodus ({', '.join(valid_modes)})")
parser.add_argument("--limit", type=int, help="Maximale Anzahl zu verarbeitender Zeilen", default=None)
parser.add_argument("--start_row", type=int, help="Startzeile im Sheet (1-basiert) für sequenzielle Modi (full_run)", default=None) # Optionaler Startpunkt für full_run
parser.add_argument("--model_out", type=str, default=MODEL_FILE, help=f"Pfad für Modell (.pkl)")
parser.add_argument("--imputer_out", type=str, default=IMPUTER_FILE, help=f"Pfad für Imputer (.pkl)")
parser.add_argument("--patterns_out", type=str, default=PATTERNS_FILE_TXT, help=f"Pfad für Regeln (.txt)")
args = parser.parse_args()
# Lade API Keys direkt am Anfang
Config.load_api_keys() # Nutzt jetzt logging.debug/info/warning intern
# Betriebsmodus ermitteln
mode = None
if args.mode and args.mode.lower() in valid_modes:
mode = args.mode.lower()
# Logge erst NACHDEM FileHandler konfiguriert ist
# logging.info(f"Betriebsmodus (aus Kommandozeile): {mode}") # Wird später geloggt
print(f"Betriebsmodus (aus Kommandozeile): {mode}") # Frühes Feedback für User
else: # Interaktive Abfrage
print("\nBitte wählen Sie den Betriebsmodus:")
print(" combined: Wiki(AX), Website(AR), Summarize(AS), Branch(AO) (Batch, Start bei leerem AO)")
print(" wiki: Nur Wikipedia-Verifizierung (AX) (Batch, Start bei leerem AX)")
print(" website: Nur Website-Scraping Rohtext (AR) (Batch, Start bei leerem AR)")
print(" summarize: Nur Website-Zusammenfassung (AS) (Batch, Start bei leerem AS)")
print(" branch: Nur Branchen-Einschätzung (AO) (Batch, Start bei leerem AO)")
print(" update_wiki: Wiki-URL aus Spalte U nach M übernehmen & ReEval-Flag setzen")
print(" reeval: Verarbeitet Zeilen mit 'x' in A (volle Verarbeitung)")
print(" find_wiki_serp: Sucht fehlende Wiki-URLs (M=k.A.) für große Firmen (>500 MA) via SerpAPI") # Neuer Modus erklärt
print(" website_lookup: Sucht fehlende Websites (D) via SerpAPI")
# print(" website_details:Extrahiert Details für Zeilen mit 'x' (AR) - EXPERIMENTELL") # Ggf. ausblenden
print(" contacts: Sucht LinkedIn Kontakte (AM)")
print(" full_run: Verarbeitet sequenziell ab erster Zeile ohne AO (alle TS prüfen)")
print(" alignment: Schreibt Header A1:AX5 (!)")
print(" train_technician_model: Trainiert Decision Tree zur Technikerschätzung")
try:
mode_input = input(f"Geben Sie den Modus ein ({', '.join(valid_modes)}): ").strip().lower()
if mode_input in valid_modes:
mode = mode_input
else:
print("Ungültige Eingabe -> Standard: combined")
mode = "combined"
except Exception as e:
print(f"Fehler Modus-Eingabe ({e}) -> Standard: combined")
mode = "combined"
# logging.info(f"Betriebsmodus (interaktiv gewählt): {mode}") # Wird später geloggt
# Zeilenlimit ermitteln
row_limit = None
if args.limit is not None:
if args.limit >= 0:
row_limit = args.limit
# logging.info(f"Zeilenlimit (aus Kommandozeile): {row_limit}") # Wird später geloggt
print(f"Zeilenlimit (aus Kommandozeile): {row_limit}") # Frühes Feedback
else:
print("Warnung: Negatives Limit ignoriert.")
# logging.warning("Warnung: Negatives Limit ignoriert.") # Wird später geloggt
row_limit = None
# Frage nur bei Modi, wo es sinnvoll ist (inkl. neuer Modus)
elif mode in ["combined", "wiki", "website", "branch", "summarize", "full_run", "reeval", "update_wiki", "find_wiki_serp"]:
try:
limit_input = input(f"Maximale Anzahl Zeilen für Modus '{mode}'? (Enter=alle): ")
if limit_input.strip():
try:
limit_val = int(limit_input)
if limit_val >= 0:
row_limit = limit_val
print(f"Zeilenlimit (interaktiv): {row_limit}")
else:
print("Negatives Limit -> Kein Limit")
row_limit = None
except ValueError:
print("Ungültige Zahl -> Kein Limit")
row_limit = None
else:
print("Kein Zeilenlimit angegeben.")
row_limit = None
except Exception as e:
print(f"Fehler Limit-Eingabe ({e}) -> Kein Limit")
row_limit = None
# Logging der Limit-Info erfolgt nach FileHandler-Setup
# --- Logdatei-Konfiguration abschließen ---
# Annahme: Funktion existiert und gibt Pfad zurück
LOG_FILE = create_log_filename(mode)
try:
file_handler = logging.FileHandler(LOG_FILE, mode='a', encoding='utf-8')
file_handler.setLevel(log_level) # Nimm das globale Level
file_handler.setFormatter(logging.Formatter(log_format))
# Füge FileHandler zum Root-Logger hinzu
logging.getLogger('').addHandler(file_handler)
logging.info(f"Logging wird jetzt auch in Datei geschrieben: {LOG_FILE}")
except Exception as e:
# Logge Fehler nur auf Konsole, da FileHandler fehlgeschlagen ist
print(f"[ERROR] Konnte FileHandler für Logdatei '{LOG_FILE}' nicht erstellen: {e}")
logging.getLogger('').handlers = [h for h in logging.getLogger('').handlers if not isinstance(h, logging.FileHandler)] # Entferne evtl. defekten Handler
logging.error(f"Konnte FileHandler für Logdatei '{LOG_FILE}' nicht erstellen: {e}")
# --- JETZT die Startmeldungen loggen (gehen jetzt in Konsole UND Datei) ---
logging.info(f"===== Skript gestartet =====")
logging.info(f"Version: {Config.VERSION}") # Sollte jetzt v1.6.5 sein
logging.info(f"Betriebsmodus: {mode}")
limit_log_text = str(row_limit) if row_limit is not None else 'N/A für diesen Modus'
if mode in ["combined", "wiki", "website", "branch", "summarize", "full_run", "reeval", "update_wiki", "find_wiki_serp"]:
limit_log_text = str(row_limit) if row_limit is not None else 'Unbegrenzt'
if row_limit == 0: limit_log_text = '0 (Keine Verarbeitung geplant)'
logging.info(f"Zeilenlimit: {limit_log_text}")
logging.info(f"Logdatei: {LOG_FILE}")
# --- Ende finale Startinfos ---
# --- Vorbereitung (Schema, Sheet Handler etc.) ---
# Annahme: Diese Funktionen verwenden jetzt logging intern
load_target_schema()
try:
sheet_handler = GoogleSheetHandler()
except Exception as e:
logging.critical(f"FATAL: Initialisierung des GoogleSheetHandlers fehlgeschlagen: {e}")
logging.critical(f"Bitte Logdatei prüfen: {LOG_FILE}")
return # Beende Skript, wenn Sheet nicht geladen werden kann
# Annahme: DataProcessor verwendet jetzt logging intern
data_processor = DataProcessor(sheet_handler)
# --- Modusausführung ---
start_time = time.time()
logging.info(f"Starte Verarbeitung um {datetime.now().strftime('%H:%M:%S')}...")
try:
# Batch-Modi über Dispatcher
if mode in ["wiki", "website", "branch", "summarize", "combined"]:
if row_limit == 0:
logging.info("Limit 0 angegeben -> Überspringe Dispatcher für Batch-Modus.")
else:
# Annahme: run_dispatcher verwendet logging intern
run_dispatcher(mode, sheet_handler, row_limit)
# Einzelne Zeilen Modi (kein Batch-Dispatcher)
elif mode == "reeval":
# Annahme: process_reevaluation_rows verwendet logging intern
data_processor.process_reevaluation_rows(row_limit=row_limit)
elif mode == "website_lookup":
# Annahme: process_serp_website_lookup_for_empty verwendet logging intern
data_processor.process_serp_website_lookup_for_empty()
elif mode == "website_details":
logging.warning("Modus 'website_details' ist experimentell.")
# Annahme: process_website_details_for_marked_rows verwendet logging intern
data_processor.process_website_details_for_marked_rows()
elif mode == "contacts":
# Annahme: process_contact_research verwendet logging intern
process_contact_research(sheet_handler)
elif mode == "full_run":
if row_limit == 0:
logging.info("Limit 0 angegeben -> Überspringe full_run.")
else:
# Prüfe, ob eine explizite Startzeile übergeben wurde
start_data_index = -1 # Initialisieren
if args.start_row and args.start_row > 5:
header_rows = 5 # Standard-Annahme
start_data_index = args.start_row - header_rows - 1 # Konvertiere zu 0-basiertem Datenindex
logging.info(f"Nutze expliziten Start-Datenindex {start_data_index} (Sheet Zeile {args.start_row}) für 'full_run'.")
else:
logging.info("Ermittle Startindex für 'full_run' (erste Zeile ohne AO)...")
# Annahme: get_start_row_index verwendet logging intern
start_data_index = sheet_handler.get_start_row_index(check_column_key="Timestamp letzte Prüfung")
# Prüfe, ob get_start_row_index einen gültigen Index zurückgab
if start_data_index != -1:
current_data = sheet_handler.get_data() # Hole aktuelle Daten
if start_data_index < len(current_data):
num_available = len(current_data) - start_data_index
num_to_process = min(row_limit, num_available) if row_limit is not None and row_limit >= 0 else num_available
if num_to_process > 0:
logging.info(f"'full_run': Verarbeite {num_to_process} Zeilen ab Daten-Index {start_data_index}.")
# Annahme: process_rows_sequentially verwendet logging intern
data_processor.process_rows_sequentially(
start_data_index,
num_to_process,
process_wiki=True,
process_chatgpt=True,
process_website=True
)
else:
logging.info("Keine Zeilen für 'full_run' zu verarbeiten (Limit/Startindex).")
else:
logging.warning(f"Startindex {start_data_index} liegt hinter der letzten Datenzeile ({len(current_data)}). Keine Verarbeitung.")
else:
# Fehlermeldung wird von get_start_row_index erwartet
logging.warning(f"Startindex für 'full_run' ungültig (Fehler bei Ermittlung oder Spalte nicht gefunden).")
elif mode == "alignment":
print("\nACHTUNG: Dieser Modus überschreibt die Header-Zeilen A1:AX5!")
try:
confirm = input("Möchten Sie wirklich fortfahren? (j/N): ").strip().lower()
except Exception as e_input:
logging.error(f"Input-Fehler bei Bestätigung: {e_input}")
confirm = 'n'
if confirm == 'j':
logging.info("Starte Alignment Demo...")
# Annahme: alignment_demo verwendet logging intern
alignment_demo(sheet_handler.sheet)
else:
logging.info("Alignment Demo abgebrochen.")
elif mode == "update_wiki":
logging.info("Starte Modus 'update_wiki'...")
# Annahme: process_wiki_updates_from_chatgpt verwendet logging intern
process_wiki_updates_from_chatgpt(sheet_handler, data_processor, row_limit=row_limit)
# --- NEUER MODUS ---
elif mode == "find_wiki_serp":
logging.info(f"Starte Modus '{mode}'...")
min_employees_for_serp = 500 # Standardwert, ggf. über Argument steuerbar machen
# Annahme: process_find_wiki_with_serp verwendet logging intern
process_find_wiki_with_serp(sheet_handler, row_limit=row_limit, min_employees=min_employees_for_serp)
# --- ENDE NEUER MODUS ---
# Block für Modelltraining
elif mode == "train_technician_model":
logging.info(f"Starte Modus: {mode}")
# Annahme: prepare_data_for_modeling verwendet logging intern
prepared_df = data_processor.prepare_data_for_modeling()
if prepared_df is not None and not prepared_df.empty:
logging.info("Aufteilen der Daten für das Modelltraining...")
try:
# Definition von X und y
X = prepared_df.drop(columns=['Techniker_Bucket', 'CRM Name', 'Anzahl_Servicetechniker_Numeric']) # CRM Name statt 'name'
y = prepared_df['Techniker_Bucket']
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, random_state=42, stratify=y)
split_successful = True
logging.info(f"Train/Test Split: {len(X_train)} Train, {len(X_test)} Test samples.")
except KeyError as e:
logging.error(f"FEHLER beim Train/Test Split: Spalte nicht gefunden - {e}. Stellen Sie sicher, dass prepare_data_for_modeling die Spalten korrekt zurückgibt.")
split_successful = False
except Exception as e:
logging.error(f"FEHLER beim Train/Test Split: {e}")
split_successful = False
if split_successful:
logging.info("Imputation fehlender numerischer Werte (Median)...")
numeric_features = ['Finaler_Umsatz', 'Finaler_Mitarbeiter']
try:
imputer = SimpleImputer(strategy='median')
if all(nf in X_train.columns for nf in numeric_features):
X_train[numeric_features] = imputer.fit_transform(X_train[numeric_features])
X_test[numeric_features] = imputer.transform(X_test[numeric_features])
imputer_filename = args.imputer_out
with open(imputer_filename, 'wb') as f_imp: pickle.dump(imputer, f_imp)
logging.info(f"Imputer erfolgreich trainiert und gespeichert: '{imputer_filename}'.")
imputation_successful = True
else:
logging.error("FEHLER: Numerische Features für Imputation nicht in Trainingsdaten gefunden.")
imputation_successful = False
except Exception as e:
logging.error(f"FEHLER bei der Imputation: {e}")
imputation_successful = False
if imputation_successful:
logging.info("Starte Decision Tree Training mit GridSearchCV...")
param_grid = {
'criterion': ['gini', 'entropy'], 'max_depth': [6, 8, 10, 12, 15],
'min_samples_split': [20, 40, 60], 'min_samples_leaf': [10, 20, 30],
'ccp_alpha': [0.0, 0.001, 0.005]
}
dtree = DecisionTreeClassifier(random_state=42, class_weight='balanced')
grid_search = GridSearchCV(estimator=dtree, param_grid=param_grid, cv=5,
scoring='f1_weighted', n_jobs=-1, verbose=1)
try:
grid_search.fit(X_train, y_train)
best_estimator = grid_search.best_estimator_
logging.info(f"GridSearchCV abgeschlossen.")
logging.info(f"Beste Parameter: {grid_search.best_params_}")
logging.info(f"Bester F1-Score (gewichtet, CV): {grid_search.best_score_:.4f}")
model_filename = args.model_out
with open(model_filename, 'wb') as f_mod: pickle.dump(best_estimator, f_mod)
logging.info(f"Bestes Modell gespeichert: '{model_filename}'.")
training_successful = True
except Exception as e_train:
logging.exception(f"FEHLER während des Trainings: {e_train}")
training_successful = False
if training_successful:
logging.info("Evaluiere Modell auf dem Test-Set...")
try:
y_pred = best_estimator.predict(X_test)
test_accuracy = accuracy_score(y_test, y_pred)
# Sicherstellen, dass Klassen als Liste von Strings übergeben werden
class_labels = [str(cls) for cls in best_estimator.classes_]
report = classification_report(y_test, y_pred, zero_division=0,
labels=best_estimator.classes_, target_names=class_labels)
conf_matrix = confusion_matrix(y_test, y_pred, labels=best_estimator.classes_)
conf_matrix_df = pd.DataFrame(conf_matrix, index=class_labels, columns=class_labels)
logging.info(f"\n--- Evaluation Test-Set ---")
logging.info(f"Genauigkeit: {test_accuracy:.4f}")
logging.info(f"Classification Report:\n{report}")
logging.info(f"Confusion Matrix:\n{conf_matrix_df}")
print(f"\nModell Genauigkeit (Test): {test_accuracy:.4f}")
logging.info("Extrahiere Baumregeln...")
try:
feature_names = list(X_train.columns)
rules_text = export_text(best_estimator, feature_names=feature_names,
show_weights=True, spacing=3)
patterns_filename = args.patterns_out
with open(patterns_filename, 'w', encoding='utf-8') as f_rules:
f_rules.write(rules_text)
logging.info(f"Regeln als Text gespeichert: '{patterns_filename}'.")
except Exception as e_export:
logging.error(f"Fehler beim Exportieren der Regeln: {e_export}")
except Exception as e_eval:
logging.exception(f"Fehler bei der Evaluation des Test-Sets: {e_eval}")
else:
logging.warning("Datenvorbereitung für Modelltraining fehlgeschlagen oder ergab keine Daten.")
else:
logging.error(f"Unbekannter Modus '{mode}' wurde zur Ausführung übergeben.")
except KeyboardInterrupt:
logging.warning("Skript durch Benutzer unterbrochen (KeyboardInterrupt).")
print("\n! Skript wurde manuell beendet.")
except Exception as e:
logging.critical(f"FATAL: Unerwarteter Fehler im Haupt-Ausführungsblock des Modus '{mode}': {e}")
logging.exception("Traceback des kritischen Fehlers:")
# --- Abschluss ---
end_time = time.time()
duration = end_time - start_time
logging.info(f"Verarbeitung abgeschlossen um {datetime.now().strftime('%H:%M:%S')}.")
logging.info(f"Gesamtdauer: {duration:.2f} Sekunden.")
logging.info(f"===== Skript beendet =====")
# Schließe Logging Handler explizit
logging.shutdown()
print(f"\nVerarbeitung abgeschlossen. Logfile: {LOG_FILE}")
# Führt die main-Funktion aus, wenn das Skript direkt gestartet wird
if __name__ == '__main__':
# --- WICHTIG: Fehlende Imports hier hinzufügen ---
import functools # Für retry decorator nötig
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split, GridSearchCV
from sklearn.impute import SimpleImputer
from sklearn.tree import DecisionTreeClassifier, export_text
from sklearn.metrics import accuracy_score, classification_report, confusion_matrix
import json
import pickle
import concurrent.futures
import threading
# --- Ende fehlende Imports ---
# --- Annahme: Decorator Definition ist hier oder importiert ---
# Beispielhafte Decorator Definition (falls nicht in separater Datei)
def retry_on_failure(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
# ... (Implementierung des Decorators wie zuvor) ...
# Minimalistische Version zur Kompilierung:
try:
return func(*args, **kwargs)
except Exception as e:
logging.warning(f"Retry wird übersprungen (Dummy-Decorator): Fehler in {func.__name__}: {e}")
raise e # Fehler weitergeben
return wrapper
# --- Ende Decorator Annahme ---
# --- Annahme: Restliche Funktionen/Klassen sind definiert ---
# z.B. Config, COLUMN_MAP, create_log_filename, load_target_schema,
# GoogleSheetHandler, WikipediaScraper, DataProcessor,
# Helper-Funktionen (simple_normalize_url etc.),
# Batch-Funktionen (run_dispatcher etc.),
# API-Funktionen (call_openai_chat etc.),
# Neue Funktionen (serp_wikipedia_lookup, process_find_wiki_with_serp)
# ...
# --- Ende Funktions/Klassen Annahmen ---
main()