Dateien nach „src“ hochladen

This commit is contained in:
Markus Benjamin Tabler 2026-08-16 20:01:50 +00:00
parent b007bace11
commit 144f57c693
4 changed files with 752 additions and 0 deletions

16
src/.env_excample Normal file
View file

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

121
src/endpoint_compare.py Normal file
View file

@ -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.<Object>} 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, [], []

111
src/fastAPI.py Normal file
View file

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

504
src/fetchWhatsNew.py Normal file
View file

@ -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.<string, {Q_Date: string, P_Date_EU20: string}>} 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()