diff --git a/src/.env_excample b/src/.env_excample new file mode 100644 index 0000000..48448c5 --- /dev/null +++ b/src/.env_excample @@ -0,0 +1,16 @@ +# Ausgabe-Format: csv oder json +OUTPUT_FORMAT=json + +# Name der Ausgabezieldatei (ohne Endung) +OUTPUT_FILENAME=2608 + +# Liste der zu scrapenden URLs (mit Komma getrennt) +URLS_TO_SCRAPE=https://help.sap.com/whats-new/983fc3e099074d1ea3ea6db6caf41678?ai=true&locale=en-US&Version=2608&Environment=API +# ,https://help.sap.com/whats-new/983fc3e099074d1ea3ea6db6caf41678?ai=true&locale=en-US&Version=2605&Environment=API,https://help.sap.com/whats-new/983fc3e099074d1ea3ea6db6caf41678?ai=true&locale=en-US&Version=2602&Environment=API + +# Dateiname des JSONs für den Vergleich innerhalb der API +COMPARE_FILENAME=compare_api.json + +LOG_PATH=docker/logs/ + +READONLY_APIKEY=your_api_key \ No newline at end of file diff --git a/src/endpoint_compare.py b/src/endpoint_compare.py new file mode 100644 index 0000000..bd7293d --- /dev/null +++ b/src/endpoint_compare.py @@ -0,0 +1,121 @@ +# @fileoverview Ausgelagerte Funktionen für die fastAPI. +# Prüft, ob der übergebene Endpunkt in der hinterlegten Datei enthalten ist. +# @author MBT +# @version 1.1 + +import os +import json +from datetime import datetime +from dateutil.relativedelta import relativedelta +from dotenv import load_dotenv + +load_dotenv() + + +def get_apis(): + """ + Lädt die API-Vergleichsdaten aus einer JSON-Datei. + Der Dateiname wird aus der Umgebungsvariable "COMPARE_FILENAME" gelesen (Standard: "compare_api.json"). + Die Datei wird relativ zum Verzeichnis des Skripts im Ordner "../output" gesucht. + + @returns {Object} Die geladenen API-Daten mit Releases oder ein Standard-Objekt bei einem Fehler. + @property {Array.} releases - Liste der Releases (im Erfolgsfall). + """ + compare_file = os.getenv("COMPARE_FILENAME", "compare_api.json").strip() + + script_dir = os.path.dirname(os.path.abspath(__file__)) + final_filepath = os.path.join(script_dir, "..", "output", compare_file) + + try: + with open(final_filepath, "r", encoding="utf-8") as f: + deprecated_api = json.load(f) + return deprecated_api + except FileNotFoundError: + print(f"Error: The file {final_filepath} was not found.") + return {"releases": []} + + +def add_one_year(date_string: str) -> str: + """ + Hilfsfunktion, um einem Datum im Format 'Month DD, YYYY' ein Jahr hinzuzufügen. + + @function add_one_year + @param {string} date_string - Das Eingangsdatum im Format "Month DD, YYYY" (z. B. "April 18, 2026"). + @returns {string} Das neue Datum genau ein Jahr später ("Month DD, YYYY"), + "Unbekannt" bei diesem Eingangswert oder "Ungültiges Datumsformat" im Fehlerfall. + """ + if date_string == "Unbekannt": + return "Unbekannt" + try: + # Parsen des SAP-Datumsformats (z. B. "April 18, 2026") + date_obj = datetime.strptime(date_string, "%B %d, %Y") + # Exakt ein Jahr hinzufügen (beachtet auch Schaltjahre sauber) + new_date_obj = date_obj + relativedelta(years=1) + # Zurück in das ursprüngliche String-Format konvertieren + return new_date_obj.strftime("%B %d, %Y") + except (ValueError, NameError): + # Fallback, falls 'relativedelta' nicht installiert ist (via timedelta) + try: + from datetime import timedelta + date_obj = datetime.strptime(date_string, "%B %d, %Y") + return (date_obj + timedelta(days=365)).strftime("%B %d, %Y") + except Exception: + return "Ungültiges Datumsformat" + +def compare_string(in_endpoint: str): + """ + Prüft, ob ein bestimmter Endpunkt von SAP-Änderungen oder Deprecations betroffen ist. + Durchsucht dafür die geladenen API-Releases nach Treffern im Feld "API-Endpoints" + und berechnet bei Deprecations die Fristen für den Rückbau (+1 Jahr). + + @function compare_string + @param {string} in_endpoint - Der zu prüfende API-Endpunkt (z. B. "GET /v1/orders"). + @returns {[string, string, string[], string[]]} Ein 4er-Tupel bestehend aus: + - [0] {string} message: Eine detaillierte Statusmeldung zur Änderung/Deprecation oder leerer String. + - [1] {string} status: Der aktuelle Status des Endpunkts (z. B. "Deprecation", "Change" oder "active"). + - [2] {string[]} deprecated: Ein Array mit den Abkündigungsdaten [Q-System, P-System]. + - [3] {string[]} decommissioned: Ein Array mit den berechneten Rückbaudaten (+1 Jahr) [Q-System, P-System]. + """ + api_data = get_apis() + for release_data in api_data.get("releases", []): + QDate = "Unbekannt" + PDate = "Unbekannt" + metadata = release_data.get("ReleaseMetadata", {}) + + if isinstance(metadata, list): + metadata = metadata[0] if metadata else {} + + if not isinstance(metadata, dict): + metadata = {} + + QDate = metadata.get("QualityUpgradeDate_Q", "Unbekannt") + PDate = metadata.get("ProductionUpgradeDate_P_EU20", "Unbekannt") + + for item in release_data.get("whats-new", []): + if in_endpoint in item.get("API-Endpoints", ""): + announcement_type = item.get("Announcement Type", "Unbekannt") + changes = item.get("Changes", "Unbekannt") + version = item.get("Announcement from Version", "Unbekannt") + + message = f"'{changes}'" + status = announcement_type + if "deprecat" in announcement_type.lower(): + if(QDate == "-"): + QDecommissioned = "-" + else: + QDecommissioned = add_one_year(QDate) + if(PDate == "-"): + PDecommissioned = "-" + else: + PDecommissioned = add_one_year(PDate) + + deprecated = [QDate, PDate] + decommissioned = [QDecommissioned, PDecommissioned] + + return message, status, deprecated, decommissioned + else: + return message, status, [], [] + + message = "" + status = "active" + return message, status, [], [] \ No newline at end of file diff --git a/src/fastAPI.py b/src/fastAPI.py new file mode 100644 index 0000000..42dbad0 --- /dev/null +++ b/src/fastAPI.py @@ -0,0 +1,111 @@ +import logging +import os +from logging.handlers import TimedRotatingFileHandler +from dotenv import load_dotenv +import secrets +from fastapi import FastAPI, Query, Depends, HTTPException, status, Request +from fastapi.security import APIKeyHeader +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel +from typing import List + +from endpoint_compare import compare_string + +load_dotenv() + +os.makedirs("logs", exist_ok=True) + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(levelname)s - %(message)s", + filename="logs/api_zugriffe.log", + filemode="a", + force=True +) +logger = logging.getLogger(__name__) + + +API_KEY_NAME = "X-API-Key" +api_key_header = APIKeyHeader(name=API_KEY_NAME, auto_error=True) + +app = FastAPI(title="SAP Endpoint Service") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +class BulkCompareRequest(BaseModel): + endpoints: List[str] + +def get_api_key(request: Request, api_key: str = Depends(api_key_header)): + client_ip = request.client.host if request.client else "Unbekannte IP" + method = request.method + path = request.url.path + + if len(api_key) > 6: + masked_key = f"{api_key[:3]}...{api_key[-3:]}" + else: + masked_key = "***" + + env_api_keys_str = os.getenv("APIKEYS", "").strip() + + if not env_api_keys_str: + logger.error("Serverkonfigurationsfehler: Keine API-Keys in .env gefunden.") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Server configuration error: API Keys not set." + ) + + valid_keys = [k.strip() for k in env_api_keys_str.split(",") if k.strip()] + + for valid_key in valid_keys: + if secrets.compare_digest(api_key, valid_key): + logger.info(f"Erfolgreicher Zugriff | IP: {client_ip} | Request: {method} {path} | Key: {masked_key}") + return api_key + + logger.warning(f"Abgelehnter Zugriff (Falscher Key) | IP: {client_ip} | Request: {method} {path} | Key: {masked_key}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid API Key" + ) + +@app.get("/") +def read_root(api_key: str = Depends(get_api_key)): + return {"status": "running", "message": "SAP Endpoint Compare API"} + +@app.get("/informations/bulk") +def compare_multiple_endpoints(endpoints: List[str] = Query(..., description="Liste der zu prüfenden Endpunkte"), + api_key: str = Depends(get_api_key) +): + + results = [] + for ep in endpoints: + message, status, deprecated, decommissioned = compare_string(ep) + results.append({ + "endpoint": ep, + "status": status, + "message": message, + "deprecated": deprecated, + "decommissioned": decommissioned + }) + + return { + "total_checked": len(results), + "results": results + } + +@app.get("/informations/{endpoint:path}") +def read_item(endpoint: str, api_key: str = Depends(get_api_key)): + message, status, deprecated, decommissioned = compare_string(endpoint) + + return { + "endpoint": endpoint, + "status": status, + "message": message, + "deprecated": deprecated, + "decommissioned": decommissioned + } \ No newline at end of file diff --git a/src/fetchWhatsNew.py b/src/fetchWhatsNew.py new file mode 100644 index 0000000..f9df003 --- /dev/null +++ b/src/fetchWhatsNew.py @@ -0,0 +1,504 @@ +# @fileoverview Automatisierter Web-Scraper für SAP Digital Manufacturing Release-Daten und API-Matrizen. +# Nutzt Selenium WebDriver zur dynamischen Inhaltsextraktion und Pandas zur Datentransformation. +# @author MBT +# @version 1.1 + +import time +import os +import re +import json +import pandas as pd +from dotenv import load_dotenv +from selenium import webdriver +from selenium.webdriver.edge.service import Service +from selenium.webdriver.edge.options import Options +from selenium.webdriver.common.by import By +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC + + +load_dotenv() + +def clean_endpoint_path(path:str): + """ + Bereinigt einen extrahierten API-Endpoint-Pfad von anhängenden Satzzeichen und Platzhaltern. + Schneidet alles ab dem Zeichen '<' ab und entfernt Suffixe wie '.', ',', ';', ':', ')', oder Whitespaces. + + @function clean_endpoint_path + @param {string} path - Der rohe, aus dem Text extrahierte API-Pfad. + @returns {string} Der bereinigte API-Pfad. + """ + cleaned = re.sub(r'[.,;:)\s]+$', '', path) + return cleaned.split('<')[0] + +def pair_matching_endpoints(deprecated_list, active_list): + """ + Vergleicht und matcht eine Liste deprecated Endpoints mit einer Liste neuer aktiver Endpoints. + Normalisiert die Pfade (Entfernung von Versionen wie /v1/ und Plural-S-Endungen), um logische Paare zu bilden. + + @function pair_matching_endpoints + @param {string[]} deprecated_list - Liste der erkannten alten Endpoints (Format: "METHOD /path"). + @param {string[]} active_list - Liste der neu angebotenen Endpoints (Format: "METHOD /path"). + @returns {Array} Ein Tuple bestehend aus: + {Array<[string, string]>} paired_results: Liste von Paaren [alter_endpoint, neuer_endpoint oder "-"]. + {string[]} unpaired_active: Liste aktiver Endpoints, die keinem veralteten Endpoint zugeordnet werden konnten. + """ + paired_results = [] + used_active = set() + + for dep in deprecated_list: + dep_method, dep_path = dep.split(" ", 1) + dep_norm = re.sub(r'/v\d+/', '/', dep_path) + dep_norm = re.sub(r'/v\d+$', '', dep_norm) + dep_norm = re.sub(r's+$', '', dep_norm) + + best_match = None + for act in active_list: + if act in used_active: + continue + act_method, act_path = act.split(" ", 1) + + if dep_method == act_method: + act_norm = re.sub(r'/v\d+/', '/', act_path) + act_norm = re.sub(r'/v\d+$', '', act_norm) + act_norm = re.sub(r's+$', '', act_norm) + + if dep_norm == act_norm: + best_match = act + break + + if best_match: + paired_results.append((dep, best_match)) + used_active.add(best_match) + else: + paired_results.append((dep, "-")) + + unpaired_active = [act for act in active_list if act not in used_active] + return paired_results, unpaired_active + +# Analysiert den Titel und die Beschreibung eines SAP "What's New"-Eintrags block- bzw. satzweise, +# identifiziert den Ankündigungstyp und extrahiert strukturierte API-Endpoint-Paare. +# @function parse_sap_description_by_blocks +# @param {string} title - Der Titel des Eintrags (enthält i.d.R. den Servicenamen). +# @param {string} description - Der mehrzeilige Fließtext mit Detailbeschreibungen und HTTP-Endpoints. +# @param {string} raw_category - Die von SAP vorgegebene Tabellen-Kategorie (z.B. "New", "Changed"). +# @param {string} version_context - Die Release-Nummer als Kontext (z.B. "2605"). +# @returns {Object[]} Liste von Objekten/Rows mit strukturierten API-Matrix-Informationen. + +def parse_sap_description_by_blocks(title:str, description:str, raw_category:str, version_context:str): + """ + Analysiert den Titel und die Beschreibung eines SAP "What's New"-Eintrags block- bzw. satzweise, + identifiziert den Ankündigungstyp und extrahiert strukturierte API-Endpoint-Paare. + + @function parse_sap_description_by_blocks + @param {string} title - Der Titel des Eintrags (enthält i.d.R. den Servicenamen). + @param {string} description - Der mehrzeilige Fließtext mit Detailbeschreibungen und HTTP-Endpoints. + @param {string} raw_category - Die von SAP vorgegebene Tabellen-Kategorie (z.B. "New", "Changed"). + @param {string} version_context - Die Release-Nummer als Kontext (z.B. "2605"). + @returns {Object[]} Liste von Objekten/Rows mit strukturierten API-Matrix-Informationen. + """ + parsed_rows = [] + + service_match = re.match(r"^([^\(]+)", title) + service_name = service_match.group(1).strip() if service_match else title + + decom_match = re.search(r"decommissioned in release (\d+)", description, re.IGNORECASE) + decom_release = decom_match.group(1) if decom_match else "-" + + endpoint_pattern = r"\b(GET|POST|PATCH|DELETE|PUT)\s+(/[a-zA-Z0-9_\-\{\}/\$<>]+)" + sentences = re.split(r'(?:\.|\n|:)\s*', description) + + deprecated_endpoints = [] + new_endpoints = [] + current_state = "generic" + + for sentence in sentences: + if not sentence.strip(): + continue + + sentence_lower = sentence.lower() + + if any(word in sentence_lower for word in ["deprecated", "decommissioned", "no longer available", "removal"]): + current_state = "deprecated" + elif any(word in sentence_lower for word in ["recommend using", "use the following", "added", "added the following"]): + current_state = "new" + + found_endpoints = re.findall(endpoint_pattern, sentence, re.IGNORECASE) + for method, path in found_endpoints: + clean_method = method.upper() + full_endpoint = f"{clean_method} {clean_endpoint_path(path)}" + + if current_state == "deprecated": + if full_endpoint not in deprecated_endpoints: + deprecated_endpoints.append(full_endpoint) + elif current_state == "new": + if full_endpoint not in new_endpoints: + new_endpoints.append(full_endpoint) + else: + if "deprecated" in sentence_lower or "decommissioned" in sentence_lower: + if full_endpoint not in deprecated_endpoints: + deprecated_endpoints.append(full_endpoint) + else: + if full_endpoint not in new_endpoints: + new_endpoints.append(full_endpoint) + + title_lower = title.lower() + desc_lower = description.lower() + + if "deprecation" in title_lower or "deprecated" in desc_lower or deprecated_endpoints: + announcement_type = "Deprecation" + elif "removal" in title_lower or "removed" in desc_lower: + announcement_type = "Removal" + elif "new" in title_lower or raw_category == "New": + announcement_type = "New" + else: + announcement_type = "Change" + + paired, unpaired_active = pair_matching_endpoints(deprecated_endpoints, new_endpoints) + + for old_ep, new_ep in paired: + parsed_rows.append({ + "Service": service_name, + "Announcement Type": "Deprecation", + "Announcement from Version": version_context, + "API-Endpoints": old_ep, + "Decommissioning in Release": decom_release, + "New Endpoints": new_ep, + "Changes": description.replace("\n", " ").strip() + }) + + for active_ep in unpaired_active: + parsed_rows.append({ + "Service": service_name, + "Announcement Type": "Change" if announcement_type == "Deprecation" else announcement_type, + "Announcement from Version": version_context, + "API-Endpoints": active_ep, + "Decommissioning in Release": "-", + "New Endpoints": "-", + "Changes": description.replace("\n", " ").strip() + }) + + if not parsed_rows: + parsed_rows.append({ + "Service": service_name, + "Announcement Type": announcement_type, + "Announcement from Version": version_context, + "API-Endpoints": "-", + "Decommissioning in Release": decom_release, + "New Endpoints": "-", + "Changes": description.replace("\n", " ").strip() + }) + + return parsed_rows + + +def extract_eu20_production_date(p_text:str): + """ + Durchsucht den Fließtext eines Production-Upgrades gezielt nach Datenzentren des Typs "EU20" + und extrahiert ausschließlich das dazugehörige Datum. + + @function extract_eu20_production_date + @param {string} p_text - Der rohe, oft mehrzeilige Text aus der Tabellenzelle des Production Upgrades. + @returns {string} Das isolierte Datum für EU20 oder ein Fallback-Wert ("-"), falls nichts gefunden wurde. + """ + if not p_text or p_text == "-": + return "-" + + # Text in Zeilen oder Abschnitte aufteilen + lines = re.split(r'\n|\r|;', p_text) + for line in lines: + if "EU20" in line: + # Sucht nach gängigen Datumsformaten (z.B. "May 16, 2026", "16.05.2026", "2026-05-16") + date_match = re.search(r'\b(?:[A-Za-z]+ \d{1,2},? \d{4}|\d{1,2}\.\d{2}\.\d{4}|\d{4}-\d{2}-\d{2})\b', line) + if date_match: + return date_match.group(0).strip() + + # Fallback: Falls das Datumsformat abweicht, entferne einfach das "Data center EU20:" Label + return re.sub(r'(?i)Data\s+centers?\s+EU20\s*:\s*', '', line).strip() + + # Fallback, falls "EU20" gar nicht explizit erwähnt wird (z.B. weil es nur ein einziges Datum gibt) + date_match = re.search(r'\b(?:[A-Za-z]+ \d{1,2},? \d{4}|\d{1,2}\.\d{2}\.\d{4}|\d{4}-\d{2}-\d{2})\b', p_text) + if date_match: + return date_match.group(0).strip() + + return p_text.strip() + + + +def scrape_sap_release_calendar(driver): + """ + Navigiert zum offiziellen SAP DMC Release-Kalender und extrahiert die Release-Namen + sowie die dazugehörigen Quality- und Production-Upgrade-Termine (für EU20). + + @function scrape_sap_release_calendar + @param {WebDriver} driver - Die aktive Selenium Edge WebDriver Instanz. + @returns {Object.} Ein Dictionary gematcht nach Release-Name. + """ + calendar_url = "https://help.sap.com/docs/sap-digital-manufacturing/release-schedule-and-dates/release-schedule-and-dates?locale=en-US" + print(f"\nScrape den SAP Release-Kalender für alle 2026er Versionen: {calendar_url}") + + calendar_data = {} + try: + driver.get(calendar_url) + wait = WebDriverWait(driver, 15) + wait.until(EC.presence_of_element_located((By.TAG_NAME, "table"))) + + tables = driver.find_elements(By.TAG_NAME, "table") + for table in tables: + headers = [th.text.strip().lower() for th in table.find_elements(By.TAG_NAME, "th")] + + # Prüfen, ob dies die korrekte DMC-Upgrade Tabelle ist + if any("release" in h for h in headers) and any("production" in h for h in headers): + + idx_release = -1 + idx_q_upgrade = -1 + idx_p_upgrade = -1 + + for i, h in enumerate(headers): + if "release" in h: + idx_release = i + elif "quality" in h or "quarterly" in h or "q-upgrade" in h: + idx_q_upgrade = i + elif "production" in h or "p-upgrade" in h: + idx_p_upgrade = i + + if idx_release != -1 and idx_q_upgrade != -1 and idx_p_upgrade != -1: + rows = table.find_elements(By.XPATH, ".//tbody/tr") + for row in rows: + cells = [td.text.strip() for td in row.find_elements(By.TAG_NAME, "td")] + + if len(cells) > max(idx_release, idx_q_upgrade, idx_p_upgrade): + release_name = cells[idx_release].replace("*", "").strip() + + if re.match(r"^\d{4}$", release_name): + raw_p_date = cells[idx_p_upgrade] + # HIER FILTERN WIR GEZIELT NUR EU20 HERAUS + cleaned_p_date = extract_eu20_production_date(raw_p_date) + + calendar_data[release_name] = { + "Q_Date": cells[idx_q_upgrade], + "P_Date_EU20": cleaned_p_date + } + break + + except Exception as e: + print(f"Warnung beim Laden des Kalenders: {e}") + + if calendar_data: + print(f"Erfolgreich geladene Release-Termine (bereinigt auf EU20):") + for rel, dates in calendar_data.items(): + print(f" - Release {rel}: Q-Upgrade -> {dates['Q_Date']} | P-Upgrade (EU20) -> {dates['P_Date_EU20']}") + return calendar_data + + +def save_all_scraped_data(full_scraped_dict:dict, calendar_data:dict, output_format:str, base_filename:str): + """ + Konsolidiert alle gescrapten Release-Informationen (Metadaten + API-Änderungen) und exportiert + diese wahlweise als JSON- oder CSV-Datei in den ./output/ Ordner. + Berücksichtigt auch zukünftige/historische Releases ohne direkte "What's New"-Einträge mittels Fallbacks. + + @function save_all_scraped_data + @param {Object} full_scraped_dict - Gescrapte API-Details pro Version. + @param {Object} calendar_data - Aus dem Kalender geladene Upgrade-Termine pro Version. + @param {string} output_format - Das gewünschte Exportformat ('csv' oder 'json'). + @param {string} base_filename - Der Basisname der Exportdatei (ohne Dateiendung). + @returns {void} + """ + # Bilde die Menge aller bekannten Releases (aus Kalender + tatsächlich gescrapt) + all_releases = set(calendar_data.keys()).union(set(full_scraped_dict.keys())) + + script_dir = os.path.dirname(os.path.abspath(__file__)) + output_dir = os.path.join(script_dir, "..", "output") + + + if output_format == "json": + final_filename = f"{base_filename}.json" + final_filepath = os.path.join(output_dir, final_filename) + output_data = {"releases": []} + + # Sortiert nach Releasename (z.B. 2511 vor 2602, 2605...) + for release_name in sorted(all_releases): + # Hole Kalender-Daten oder setze leeren Fallback anstelle eines KeyErrors + release_info = calendar_data.get(release_name, {"Q_Date": "-", "P_Date_EU20": "-"}) + whats_new_list = full_scraped_dict.get(release_name, []) + + output_data["releases"].append({ + "ReleaseMetadata": { + "ReleaseName": release_name, + "QualityUpgradeDate_Q": release_info["Q_Date"], + "ProductionUpgradeDate_P_EU20": release_info["P_Date_EU20"] + }, + "whats-new": whats_new_list + }) + + with open(final_filepath, "w", encoding="utf-8") as f: + json.dump(output_data, f, indent=4, ensure_ascii=False) + print(f"\n-> JSON erfolgreich in '{final_filename}' gespeichert. ({len(all_releases)} Releases abgedeckt).") + + else: # CSV Format + final_filename = f"{base_filename}.csv" + final_filepath = os.path.join(output_dir, final_filename) + rows_to_save = [] + + for release_name in sorted(all_releases): + release_info = calendar_data.get(release_name, {"Q_Date": "-", "P_Date_EU20": "-"}) + whats_new_list = full_scraped_dict.get(release_name, []) + + if whats_new_list: + for entry in whats_new_list: + entry_copy = entry.copy() + entry_copy["Quality Upgrade (Q)"] = release_info["Q_Date"] + entry_copy["Production Upgrade (P - EU20)"] = release_info["P_Date_EU20"] + rows_to_save.append(entry_copy) + else: + # Zeilen-Fallback für zukünftige Releases, für die es noch keine Whats-New Daten gibt + rows_to_save.append({ + "Service": "-", + "Announcement Type": "-", + "Announcement from Version": release_name, + "Quality Upgrade (Q)": release_info["Q_Date"], + "Production Upgrade (P - EU20)": release_info["P_Date_EU20"], + "API-Endpoints": "-", + "Decommissioning in Release": "-", + "New Endpoints": "-", + "Changes": "Keine Whats-New Einträge für dieses Release gelistet." + }) + + df = pd.DataFrame(rows_to_save) + columns_order = [ + "Service", "Announcement Type", "Announcement from Version", + "Quality Upgrade (Q)", "Production Upgrade (P - EU20)", + "API-Endpoints", "Decommissioning in Release", "New Endpoints", "Changes" + ] + df = df[columns_order] + df.to_csv(final_filepath, index=False, encoding="utf-8-sig", sep=";") + print(f"\n-> CSV erfolgreich in '{final_filename}' gespeichert. ({len(df)} Gesamtzeilen).") + +# Ruft die spezifische SAP "What's New"-Übersichtsseite einer Version auf, wartet auf das Rendern +# der dynamischen Tabelleninhalte und extrahiert zeilenweise die Rohdaten. +# @function scrape_sap_version +# @param {WebDriver} driver - Die aktive Selenium Edge WebDriver Instanz. +# @param {string} url - Die vollständige Ziel-URL inklusive des "Version=XXXX" Parameters. +# @returns {Array} Ein Tuple bestehend aus: +# {Object[]} version_data: Die vollständig geparsten und strukturierten Zeilen für diese Version. +# {string} version_context: Die extrahierte Versionsnummer (z.B. "2511"). + +def scrape_sap_version(driver, url:str): + """ + Ruft die spezifische SAP "What's New"-Übersichtsseite einer Version auf, wartet auf das Rendern + der dynamischen Tabelleninhalte und extrahiert zeilenweise die Rohdaten. + + @function scrape_sap_version + @param {WebDriver} driver - Die aktive Selenium Edge WebDriver Instanz. + @param {string} url - Die vollständige Ziel-URL inklusive des "Version=XXXX" Parameters. + @returns {Array} Ein Tuple bestehend aus: + {Object[]} version_data: Die vollständig geparsten und strukturierten Zeilen für diese Version. + {string} version_context: Die extrahierte Versionsnummer (z.B. "2511"). + """ + version_match = re.search(r"Version=(\d+)", url) + version_context = version_match.group(1) if version_match else "2605" + + print(f"\n--- Scraping Version {version_context} ---") + print(f"Lade Webseite: {url}") + driver.get(url) + + wait = WebDriverWait(driver, 25) + + try: + cookie_button = driver.find_element(By.ID, "truste-consent-required") + if cookie_button.is_displayed(): + cookie_button.click() + time.sleep(2) + except Exception: + pass + + print("Warte auf das Rendern der Tabellenzeilen...") + wait.until(EC.presence_of_element_located((By.XPATH, "//tr[starts-with(@id, 'whats-new-')]"))) + + table = driver.find_element(By.ID, "whats-new") + tbody_rows = table.find_elements(By.XPATH, ".//tbody/tr[starts-with(@id, 'whats-new-')]") + + version_data = [] + print(f"Scrape {len(tbody_rows)} Rohdaten-Zeilen...") + for row in tbody_rows: + cells = row.find_elements(By.TAG_NAME, "td") + if len(cells) < 5: + continue + + raw_category = cells[2].text.strip() + title = cells[3].text.strip() + description = cells[4].text.strip() + + structured_rows = parse_sap_description_by_blocks(title, description, raw_category, version_context) + version_data.extend(structured_rows) + + return version_data, version_context + + + +def main(): + """ + Hauptsteuerungsfunktion (Main-Entrypoint). Lädt Umgebungsvariablen, initialisiert den Edge-Browser + im Headless-Modus, steuert den sequentiellen Scraping-Ablauf über Kalender und Versionen hinweg + und sorgt für den finalen Datenexport sowie das sichere Schließen des Browsers. + @function main + @returns {void} + """ + output_format = os.getenv("OUTPUT_FORMAT", "csv").lower().strip() + base_filename = os.getenv("OUTPUT_FILENAME", "sap_api_matrix").strip() + + # Die bereits veröffentlichten URLs, die wir aktiv abfragen wollen + urls_raw = os.getenv("URLS_TO_SCRAPE", "") + if not urls_raw: + print("[FEHLER] Keine URLs in der Variable 'URLS_TO_SCRAPE' in der .env gefunden!") + return + + urls_to_scrape = [url.strip() for url in urls_raw.split(",") if url.strip()] + + print("Starte Edge-Browser im Firmennetzwerk-Modus...") + edge_options = Options() + edge_options.add_argument("--headless") + edge_options.add_argument("--window-size=1920,1080") + edge_options.add_argument("--lang=en-US") + edge_options.add_argument("--ignore-certificate-errors") + edge_options.add_argument("--allow-running-insecure-content") + + local_driver_path = os.path.join(os.path.dirname(__file__), "..","msedgedriver.exe") + + try: + service = Service(executable_path=local_driver_path) + driver = webdriver.Edge(service=service, options=edge_options) + except Exception as err: + print(f"\n[FEHLER] Edge-Initialisierung fehlgeschlagen: {err}") + return + + try: + # 1. Kalender-Daten dynamisch abfragen + calendar_data = scrape_sap_release_calendar(driver) + if not calendar_data: + print("[FEHLER] Kalender-Daten konnten nicht geladen werden.") + return + + full_scraped_dict = {} + + # 2. Releases aus den URLs nacheinander im Browser abarbeiten + for url in urls_to_scrape: + try: + scraped_data, version = scrape_sap_version(driver, url) + if scraped_data: + full_scraped_dict[version] = scraped_data + except Exception as e: + print(f"Fehler beim Scraping der URL ({url}): {e}") + + # 3. Speichere alle Daten (inklusive leerer Fallbacks für nicht gescrapte Releases wie 2608, 2611) + save_all_scraped_data(full_scraped_dict, calendar_data, output_format, base_filename) + + finally: + try: + driver.quit() + except: + pass + print("\nScraping-Prozess beendet.") + +if __name__ == "__main__": + main() \ No newline at end of file