Ziel dieses Projektes
Bisher verwendete ich den Zeta-Producer zur Erzeugung meiner Webseite. Dieses Werkzeug ist für Privatanwender im eingeschränkten Rahmen kostenlos und man kann damit auch ganz gut arbeiten. Leider ist es nur für das Betriebssystem Windows verfügbar. Deshalb musste ich extra für diese Anwendung ein virtuelle Maschine aufsetzen. Da mein PC nicht mehr ganz dem aktuellen Stand entspricht lief diese VM mit Windows 11 sehr zäh. Schließlich war die Hardware den Test für Windows 11 nicht gemeistert. Ich verwende seit ca. 2014 Ubuntu Desktop zu Hause und wollte diese Windows-VM deshalb abschalten. Dabei wollte ich aber auch weiterhin meine Homepage auf meiner persönlichen Hardware gestalten und nur die fertigen Seiten in das Internet stellen.
Mit dem Plugin simply static kann WordPress offline genutzt werden, um statische HTML-Seiten zu erstellen. Es genügt die kostenlose Version des plugins. Natürlich gehen dadurch auch alle dynamischen Funktionen wie aktive Formulare usw. verloren. Das war in meinem Fall aber kein Problem, da ich nur eine statische Webseite realisieren wollte. Da auch die WordPress-Suche ohne php nicht funktioniert, habe ich mir dafür eine kleine Lösung in Go programmiert. Damit werden alle Wörter gesucht und mit der dazugehörigen Seite in einer json-Datei als Datenbank gespeichert. Ein Java-Script nimmt dann diese Datenquelle zur Erzeugung der Liste mit den Suchbegriffen.
Was wird benötigt ?
- Hardware
Ich habe einen Raspberry Pi 4 verwendet.
Natürlich genügt auch ein kleinerer Raspberry Pi oder eine Virtuelle Maschine mit eine Linus-System darauf.
Ein 64-Bit-Betriebssystem – ich verwende Raspian Trixie Desktop mit Stand Dezember 2025 - Software
Docker – zur Bereitstellung der Container
Golang – zur Kompilierung des kleinen Go-Programms (falls Sie ein anderes Betriebssystem/Prozessor verwenden)
Bereitstellung des Rasperry Pi
Verwenden Sie den Raspberry Pi Imager ab Version 2.0 um das Betriebssystem auf die SD-Karte oder einen USB–Stick zu übertragen. Damit die gewünschten Einstellungen korrekt auf das Zielsystem übertragen werden, ist bei der Verwendung von Trixie mindestens die Version 2.0 des Imager erforderlich.
Ich verwende in diesem Projekt den Benutzer pi als Standardbenutzer auf dem Raspberry Pi. Deshalb müssen Sie bei der Eingabe der Befehle immer die Angabe pi durch Ihren Benutzernamen ersetzen
Die Einrichtung des Projektes beginnt:
Wir aktualisieren das Betriebssystem und installieren Geany als Editor. Ich verwende den Editor auch beim Zugriff per ssh -X. Selbstverständlich kann das Projekt auch mit dem eingebauten Editor nano durchgeführt werden. Die libcanberra-Module werden zur Anzeige der grafischen Oberfläche benötigt und tree wird nur zur übersichtlichen Darstellung der Verzeichnisstruktur benötigt.
# Aktualisierung des Betriebssystems
sudo apt update
sudo apt upgrade
#Den Editor geany installieren
sudo apt install geany geany-common libcanberra-gtk3-0 libcanberra-gtk3-module tree
Falls Sie Ihnen beim Aufruf von Geany kein grafisches Eingabefenster gezeigt wird, müssen Sie in raspi-config noch auf X11-Anzeige umschalten:
sudo raspi-config
# dann folgend Menüfolge:
# 6 Advanced Option
# A7 Wayland
# W1 X11 Openbox Window manager with X11 backend
# aktivieren
#
# falls in Geany keine deutschsprachige Oberfläche erscheint
# 5 Localisation Options
# L1 Locale
# und de_DE.UTF-8 einstellen.
# nach einem Reboot sollte alles wie gewünscht funktionieren
Als nächstes folgt die Installation von Docker
#Docker und Docker Compose installieren
#evtl. vorhandene alte Version deinstallieren
sudo apt remove $(dpkg --get-selections docker.io docker-compose docker-doc podman-docker containerd runc | cut -f1)
#Docker Repository verwenden
# Add Docker's official GPG key:
sudo apt update
sudo apt install ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
# Das Repository in apt hinzufuegen
sudo tee /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/debian
Suites: $(. /etc/os-release && echo "$VERSION_CODENAME")
Components: stable
Signed-By: /etc/apt/keyrings/docker.asc
EOF
sudo apt update
#aktuelle Version installieren
sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Exkurs: Für die Installation in einer VM mit Ubuntu folgende Befehle verwenden:
#********************* Ubuntu ***************************************
# Dockerinstallation
# System aktualisieren, benötigte Pakete installieren [exi
sudo apt-get update
sudo apt-get install ca-certificates curl gnupg
# GPG-Schlüssel hinzufügen und Repository einrichten [1]
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
#**************** Ende Ubuntu *******************************
Damit docker ohne root-Rechte gestartet werden kann, den Benutzer zur Gruppe docker hinzufügen. Damit die neue Zuordnung auch wirksam wird, muss sich der Benutzer einmal ab- und wieder anmelden. Ein Reboot bewirkt es natürlich auch.
#User der Gruppe hinzufügen
sudo groupadd docker
sudo usermod -aG docker $USER
#einmal neu starten, damit die Gruppenzuordnung wirkt - Neuanmeldung des Users würde auch genügen
sudo reboot
Nach der erneuten Benutzeranmeldung sollte die Versionsanzeige von Docker funktionieren:
#Anzeige der Version
docker version
Grafische Oberfläche zur Verwaltung der Container
Falls Sie später Ihre Container in einer grafischen Oberfläche verwalten möchten, können Sie jetzt gleich das System portainer als Ihren ersten Container laufen lassen.
# Docker-Verwaltungstool portainer installieren
# persistenten Speicher für Portainer erstellen
docker volume create portainer_data
# Portainer-Container starten
docker run -d -p 8000:8000 -p 9443:9443 --name portainer --restart=always -v /var/run/docker.sock:/var/run/docker.sock -v portainer_data:/data portainer/portainer-ce:latest
Aufruf der Oberfläche im Browser lokal auf dem Zielrechner in der ssh-Umgebung:
firefox https://localhost:9443/
Programmiersprache golang installieren
Um das von mir erstellte Go-Programm in eine ausführbare Datei zu kompilieren, wird die Programmiersprache golang benötigt. Eine ausfürbare Datei für den arm7-Prozessor des Raspi 4 für 64-Bit-Betrieb finden Sie weiter unten zum Download. Das Kompilieren ist nur notwendig, wenn Sie auf einem anderen Betriebssystem oder Prozessor arbeiten oder wenn Sie Änderungen am Quellcode vornehmen möchten.
# Die Programmiersprache Golang installieren
sudo apt install golang-go
# Version anzeigen
go version
Verzeichnisstruktur anlegen
Ich verwende das Verzeichnis /srv um darin im Unterverzeichnis containers die jeweiligen Container anzulgen. Das Verzeichnis /srv ist bereits vorhanden mit der Benutzergruppe root:root.
sudo chown pi:www-data /srv -R
#Verzeichnisstruktur
mkdir -p /srv/containers/wordpress/go-indexer
#leere Dateien für go-Programm
touch /srv/containers/wordpress/go-indexer/go.main
touch /srv/containers/wordpress/go-indexer/build.sh
Definition der Container festleglen
In der Datei docker-compose.ym werden die gewünschten Services und die benötigten Zugangsdaten für die Datenbank konfiguriert:
geany /srv/containers/wordpress/docker-compose.yml
Dabei werden folgende Inhalte für die Variablen verwendet:
- Hostname des Datenbankservers: mariadb
- Datenbank für die WordPress-Inhalte: wordpress
- Benutzername zur Datenbank: wpdata
- Kennwort des Datenbankbenutzers: wppassword
und folgende Dienste eingerichtet:
- mariadb
die verwendete Datenbank - wordpress auf Port 80
http://localhost/ die lokale Webseite
http://localhost/wp-admin/ das Dashboard von WordPress
der Server mit der WordPressinstanz - phpmyadmin auf Port 8081
http://localhost:8081/
Weboberfläche, um die Datenbank manuell pflegen zu können.
Die Zeilen platform: linux/arm64/v8 wird nur benötigt, wenn docker auf einem Arm-Prozessor im 64-Bit-System läuft. Sie können bei Bedarf mit einem #-Zeichen zu Beginn der Zeile auskommentiert werden.
Gesamthinhalt der Datei:
services:
mariadb:
image: mariadb:latest
container_name: mariadb
restart: unless-stopped
environment:
MYSQL_DATABASE: wordpress
MYSQL_ROOT_PASSWORD: mariaroot
MYSQL_USER: wpdata ## Hier selben Benutzer eingeben ##
MYSQL_PASSWORD: wppassword ## Hier selbes Passwort eingeben ##
MYSQL_RANDOM_ROOT_PASSWORD: '1'
volumes:
- ./database:/var/lib/mysql
networks:
- wp
wordpress:
image: wordpress:latest
platform: linux/arm64/v8
restart: unless-stopped
container_name: wordpress
environment:
WORDPRESS_DB_HOST: mariadb
WORDPRESS_DB_USER: wpdata ## Hier Benutzer eingeben ##
WORDPRESS_DB_PASSWORD: wppassword ## Hier Passwort eingeben ##
WORDPRESS_DB_NAME: wordpress
ports:
- "80:80"
volumes:
- ./wp_data:/var/www/html
networks:
- wp
phpmyadmin:
image: phpmyadmin:latest
platform: linux/arm64/v8
restart: always
depends_on:
- mariadb
environment:
PMA_HOST: mariadb
MYSQL_ROOT_PASSWORD: mariaroot
ports:
- "8081:80"
networks:
- wp
volumes:
wp_data:
cronjob:
networks:
wp:
driver: bridge
Erster Start der Container
Nun können wir unsere Server starten lassen. Beim ersten Start benötigen wir noch Internetverbindung, da docker erst die benötigten Image-Dateien für unsere Service-Container herunterladen muss. Diesen Vorgang nennt man pulling und er kann einige Minuten dauern. Zum Start in das Verzeichnis mit der docker-compose.yml gehen und auf der Konsole folgenden Befehl eingeben:
docker compose up -d &
Das &-Zeichen am Ende bewirkt, dass die Container auch weiterlaufen, wenn wir die Konsole verlassen. Das Herunterfahren der Container erreicht man mit
docker compose down
Um zu sehen, ob alle Container laufen können Sie in oben installierte Dashbord vom Portainer gehen
firefox https://localhost:9443/
oder einfach mit folgendem Befehl anzeigen lassen:
docker ps
Nun können Sie die Weboberfläche starten und die Ersteinrichtung von WordPress durchführen. Ich gehe hier aber nicht auf die Details zur Durchführng dieser Ersteinrichtung ein.
firefox http://localhost/wp-admin/
Plugins installieren
Um die gewünschte Vorgehensweise zur Veröffentlichung einer statischen HML-Webseite zu erreichen, benötigen wir einige Erweiterung in Form von frei verfügbaren Plugins. Ich habe im rechten Berech die Links zu den benötigten Plugins aufgeführt und auch die von mir aktuell genutzte Version verlinkt. Die aktualisierte Version können Sie im Dashboard von WordPress erhalten.
Einfach im Dashboard -> Plugins -> neues Plugin die ZIP-Dateien hochladen und aktivieren.
Simply Static
ermöglicht die Veröffentlichung von statischen Webseiten aus WordPress heraus. Es genügt die kostenlose Version. Die fehlende Funktionalität der Suche ergänzen wir durch eigene Software.
disable-remove-google-fonts
stoppt das unerwünschte Nachladen von goolge-fonts auf Ihrer Webseite.
- Simply Static
simply-static.zip - disable-remove-google-fonts
disable-remove-google-fonts.zip - mirror-simply
Eigenentwicklung
mirror-simply.zip
mirror-simply
ist ein selbstentwickeltes Plugin und enthält folgende Funktionen:
search_indexer Ermittlung einer Wörterliste und daraus Erstellung einer index.json als Datenbasis zur Anzeige der gefunden Wörter. Abschaltung der internen Suchmaske von WordPress. Einbindung der durchzuführenden Arbeiten am Ende des Aktivitätsprotokoll von simply static. Weiterhin ist eine Batchdatei zum manuellen Aufruf der Synchronisation des Offline-Verzeichnisses mit dem Webserver
Einbinden unsers eigenen Suchfeldes
Nachdem das Plugin mirror_simply aktiviert wurde, steht das Widget JS_Such zur Verfügung. Sie müssen es nur in den gewünschten Bereich Ihres Templates einfügen. Bei mir liegt es im Header.
Menüfolge Design -> Widgets dann im Suchfeld JS eingeben und das Widget in den Zielbereich einfügen.

Konfiguration von Simply Static
Sie können ganz einfach meine Einstellungen komplett in Simply Static übernehmen:
Menüfolge: Simply Static -> Einstellungen -> Werkzeuge -> Eingabeld für Import

Hier meine kompletten Daten
{
"_locale": "user",
"encryption_key": "234ed43c7c6b04a6b4a223afbcf37714",
"destination_scheme": "https://",
"destination_host": "www.lang-dieter.de/wp/",
"additional_urls": "/wp/posts/2026/\n/wp/posts/2026/01/\n/wp/posts/2026/02/\n/wp/posts/2026/03/\n/wp/posts/2026/04/\n/wp/posts/2026/05/\n/wp/posts/2026/06/\n/wp/posts/2026/07/\n/wp/posts/2026/08/\n/wp/posts/2026/09/\n/wp/posts/2026/10/\n/wp/posts/2026/11/\n/wp/posts/2026/12/\n/wp/posts/2025/\n/wp/posts/2025/01/\n/wp/posts/2025/02/\n/wp/posts/2025/03/\n/wp/posts/2025/04/\n/wp/posts/2025/05/\n/wp/posts/2025/06/\n/wp/posts/2025/07/\n/wp/posts/2025/08/\n/wp/posts/2025/09/\n/wp/posts/2025/10/\n/wp/posts/2025/11/\n/wp/posts/2025/12/\n/wp/posts/2024/\n/wp/posts/2024/01/\n/wp/posts/2024/02/\n/wp/posts/2024/03/\n/wp/posts/2024/04/\n/wp/posts/2024/05/\n/wp/posts/2024/06/\n/wp/posts/2024/07/\n/wp/posts/2024/08/\n/wp/posts/2024/09/\n/wp/posts/2024/10/\n/wp/posts/2024/11/\n/wp/posts/2024/12/",
"additional_files": "/var/www/html/wp-content/uploads",
"urls_to_exclude": "wp-content/uploads/simply-static",
"delivery_method": "local",
"relative_path": "/wp",
"destination_url_type": "relative",
"debugging_mode": "1",
"server_cron": "",
"whitelist_plugins": "",
"origin_url": "",
"force_replace_url": "1",
"clear_directory_before_export": "1",
"iframe_urls": "",
"iframe_custom_css": "",
"tiiny_subdomain": "",
"tiiny_domain_suffix": "tiiny.site",
"tiiny_password": "",
"cdn_api_key": "",
"cdn_storage_host": "storage.bunnycdn.com",
"cdn_access_key": "",
"cdn_directory": "",
"github_account_type": "personal",
"github_user": "",
"github_email": "",
"github_personal_access_token": "",
"github_repository_visibility": "public",
"github_branch": "main",
"github_webhook_url": "",
"github_folder_path": "",
"github_throttle_requests": "",
"aws_auth_method": "aws-iam-key",
"aws_region": "us-east-2",
"aws_access_key": "",
"aws_access_secret": "",
"aws_subdirectory": "",
"aws_distribution_id": "",
"aws_webhook_url": "",
"aws_empty": "",
"s3_access_key": "",
"s3_base_url": "",
"s3_access_secret": "",
"s3_subdirectory": "",
"fix_cors": "allowed_http_origins",
"static_url": "",
"use_forms": "",
"use_comments": "",
"comment_redirect": "",
"use_search": "",
"search_type": "fuse",
"search_index_title": "title",
"search_index_content": "body",
"search_index_excerpt": ".entry-content",
"search_excludable": "",
"search_metadata": "",
"fuse_selector": ".search-field",
"fuse_threshold": "0.1",
"algolia_app_id": "",
"algolia_admin_api_key": "",
"algolia_search_api_key": "",
"algolia_selector": ".search-field",
"use_minify": "",
"minify_html": "",
"minify_css": "",
"minify_inline_css": "",
"minify_js": "",
"minify_inline_js": "",
"generate_404": "1",
"custom_404_page": "0",
"add_feeds": "",
"add_rest_api": "",
"smart_crawl": "1",
"wp_content_folder": "",
"wp_includes_folder": "",
"wp_uploads_folder": "",
"wp_plugins_folder": "",
"wp_themes_folder": "",
"theme_style_name": "style",
"author_url": "",
"hide_comments": "",
"hide_version": "",
"hide_generator": "",
"hide_prefetch": "",
"hide_rsd": "",
"hide_emotes": "",
"disable_xmlrpc": "",
"disable_embed": "",
"disable_db_debug": "",
"disable_wlw_manifest": "",
"sftp_host": "",
"sftp_user": "",
"sftp_pass": "",
"sftp_port": "22",
"version": "3.6.3",
"integrations": [
"ss-adminbar"
],
"ss_use_single_exports": true,
"ss_use_builds": false,
"ss_single_include_categories": true,
"ss_single_include_tags": true,
"ss_single_include_archives": true,
"ss_single_include_pagination": true,
"ss_single_export_add_xml_sitemap": false,
"ss_single_auto_export": false,
"ss_single_auto_export_delay": 3,
"ss_single_export_webhook_url": "",
"ss_webhook_url": "",
"ss_webhook_enabled_types": [
"export",
"update",
"build",
"single"
],
"ss_single_taxonomy_archives": [
"category",
"post_tag"
],
"post_types": [
"post",
"page"
],
"crawlers": [
"author",
"home",
"pagination",
"plugin_assets",
"post_type",
"sitemap",
"taxonomy",
"theme_assets",
"uploads",
"vendor_files",
"wp_includes"
],
"generate_type": "export"
}
Die Destination-URL und Destination-Hosts müssen natürlich angepasst werden. Bei den Destination Hosts muss der lokale Hostnamen eingetragen werden. Sie umfassen jeweils die Unterordner mit den Posts nach Jahr / Monat sortiert. Dies ist notwendig, damit online die Beiträge auch in den Archiv-Seiten vorhanden sind.
Erste Erstellung der statischen Seiten starten
Wenn die Menüfolge Simply Static -> Generieren -> Diagnose
keine Fehler mehr meldet, kann die Schaltfläche Generieren betätigt werden

Im Aktivitätsprotokoll können Sie den Ablauf der Seitenverabeitung verfolgen:

Die markierte Zeile zeigt den erfolgreichen Verlauf unseres eigenen Plugins mirror_simply an.
Jetzt kann in der ssh-Konsole die Übertragung der Seiten auf den Webserver angestossen werden:
sudo /srv/containers/wordpress/wp_data/wp-content/plugins/mirror_simply/mirror.sh
Diese Datei muss in der Zeile mit dem rsync-Aufruf angepast werden. Mit folgendem Inhalt arbeitet die Datei bei mir (das Ziel vom rsync habe ich durch eine allgemein Variable erstzt)
geany /srv/containers/wordpress/wp_data/wp-content/plugins/mirror_simply/mirror.sh
#!/bin/bash
#Dateiberechtigungen allgemein für WordPress setzen
#sudo chown -R www-data:www-data /srv/containers/wordpress && sudo find /srv/containers/wordpress -type d -exec chmod 755 {} \;&& sudo find /srv/containers/wordpress -type f -exec chmod 766 {} \;
sudo chown -R pi:www-data /srv/containers/wordpress/go-indexer/
# Die index.json auch in das lokale Verzeichnis kopieren
JSONDATA="/srv/containers/wordpress/wp_data/offline/index.json"
LOKALJSONDATA="/srv/containers/wordpress/wp_data/wp/index.json"
echo "Kopie der index.json in das lokale WP-Verzeichnis"
ls -la $JSONDATA
cp $JSONDATA $LOKALJSONDATA
ls -la $LOKALJSONDATA
#Schalterdatei suchen
SCHALTER="/srv/containers/wordpress/wp_data/offline/rsync_schalter.txt"
if [ -f "$SCHALTER" ]; then
sudo rm SCHALTER
rsync -avz --delete -e "ssh -i /home/pi/.ssh/id_rsa" /srv/containers/wordpress/wp_data/offline/ [benutzer@zielhost:/zielverszeichnis/webpraesenz]
sudo rm $SCHALTER
sudo chmod 777 /srv/containers/wordpress/wp_data/offline -R
sudo rm -rf /srv/containers/wordpress/wp_data/offline/*
sudo ls -la /srv/containers/wordpress/wp_data/offline/
sudo rm -rf /srv/containers/wordpress/wp_data/wp-content/uploads/simply-static/temp-files/*
sudo ls -la /srv/containers/wordpress/wp_data/wp-content/uploads/simply-static/temp-files/
else
echo "Datei $SCHALTER ist nicht vorhanden. Nichts zu tun."
fi
Alternativ dazu können Sie das Verzeichnis /srv/containers/wordpress/wp_data/offline/ mit einem FTP-Programm Ihrer Wahl auf den Webserver übertragen.
Platz für unser selbst erstelltes Go-Programm
Das Verzeichnis beinhaltet unser selbst erstelltes go-programm. Der Quellcode liegt in der Datei main.go. Öffnen Sie diese also im Editor
geany /srv/containers/wordpress/go-indexer/main.go
versorgen Sie diese mit Inhalt:
package main
import (
"bufio"
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"sync"
"flag"
"time"
"golang.org/x/net/html"
"regexp"
"sort"
"encoding/xml"
)
var (
index = make(map[string][]string)
mutex sync.Mutex
stopWords = make(map[string]bool)
stopPhrases []string
indexFile = "index.json"
// Die erlaubten WordPress-Klassen
allowedClasses = []string{"entry-header", "entry-info", "entry-content"}
)
type WordEntry struct {
Wort string `json:"wort"` // oder wie auch immer das Feld im JSON heißt
URL string `json:"url"` // Das Feld, das die Web-Adresse enthält
}
// XML Strukturen für das gewünschte Sitemap-Format
type URLSet struct {
XMLName xml.Name `xml:"urlset"`
Xmlns string `xml:"xmlns,attr"`
XmlnsXsi string `xml:"xmlns:xsi,attr"`
XsiSchemaLoc string `xml:"xsi:schemaLocation,attr"`
URLs []URLEntry `xml:"url"`
}
type URLEntry struct {
Loc string `xml:"loc"`
LastMod string `xml:"lastmod"`
ChangeFreq string `xml:"changefreq"`
Priority float64 `xml:"priority"`
}
func main() {
//Rückgabewert des Programms
erg := 0
//Argumente aus der Befehlszeile übernehmen
//Definiton der Vorgaben für die Flags
sourceDir := flag.String("if","../../../offline/","Verzeichnis mit den einzulesenden HTML-Dateien")
targetDir := flag.String("of","../../../offline/","Ausgabeverzeichnis für die Datei index.json")
prefix := flag.String("prefix","/wp/","Prefix für die Links der Suchergebnisse falls online die HTML-Daten nicht im Hauptverzeichnis der Domain liegen")
baseURL := flag.String("baseURL","https://www.lang-dieter.de","BaseURL der Webseite zum Eintrag in die Sitemap.xml")
cli := flag.Bool("cli",false,"Am Ende des Programms testweise manuell in der Kommandozeile eine Suche durchführen")
help := flag.Bool("help",false,"Anzeige eines kleinen Hilfetextes")
//Flags parsen
flag.Parse()
//Der Hilfetext wurde angefordert
if *help {
// os.Args[0] enthält den Pfad, mit dem das Programm aufgerufen wurde
exePath := os.Args[0]
// Nur den Dateinamen ohne Pfad extrahieren
exeName := filepath.Base(exePath)
fmt.Println("Aufruf des Programmes mit:")
fmt.Println(exeName," --if=[Inputpath] --of=[Outputpath] -prefix=/wp -help Anzeige des Hilfetextes")
fmt.Println("")
fmt.Println("if = Quellverzeichnis: dort liegen die zu durchsuchenden Dateien")
fmt.Println(" ")
fmt.Println("of = Zielverzeichnis: dort wird das Érgebnis als indx.json abgelegt")
fmt.Println(" ")
fmt.Println("prefix = Präfix für die Dateinamen als Link auf dem Zielrechner Default: /wp/posts")
fmt.Println(" hier bitte keinen Pfadseperator am Ende angeben !")
fmt.Println(" ")
fmt.Println("baseURL = URL der Startseit (für Eintrag in sitemap.xml)")
fmt.Println("")
fmt.Println("cli = Am Ende des Programms können Sie testmweise manuell nach Begriffen auf der Kommandozeile suchen")
fmt.Println(" ")
fmt.Println("Fehlercodes:")
fmt.Println("2: Fehler beim Scannen der Dateien")
fmt.Println("3. Fehler beim Schreiben der Datei index.json")
fmt.Println("4. Fehler beim Anlegen der Datei rsync_schalter.txt")
fmt.Println("5. Fehler beim Beschreiben der Datei rsync_schalter.txt")
os.Exit(1)
} else {
//aktuellen Pfad mit Programmnamen und Argumente anzeigen
fmt.Println(os.Args[0])
fmt.Println("sourceDir:",*sourceDir)
fmt.Println("targetDir:",*targetDir)
fmt.Println("baseURL;",*baseURL)
fmt.Println("prefix:",*prefix)
fmt.Println("cli:",*cli)
fmt.Println("help",*help)
}
//Fehlende Pfadtrenner am Ende ergänzen
searchDir := ensureTrailingSeparator(*sourceDir)
zielpfad := ensureTrailingSeparator(*targetDir)
// Zusätzlich übergebene Argumente ausgeben
if len(flag.Args()) > 0 {
fmt.Println("unbekannte Befehlszeilenargumente:", flag.Args())
}
loadStopWords("stop-words.txt")
var wg sync.WaitGroup
// 1. Regex VOR der Schleife EINMAL definieren (Performance + Fehlervermeidung)
//reDatePath := regexp.MustCompile(`/\d{4}/\d{2}/index\.html$`)
reDatePath := regexp.MustCompile(`/\d{4}/(\d{2}/)?index\.html$`)
err := filepath.WalkDir(searchDir, func(dateiPfad string, d fs.DirEntry, err error) error {
if err == nil && !d.IsDir() && strings.HasSuffix(strings.ToLower(d.Name()), ".html") {
wg.Add(1)
go func(p string) { // p statt dateiPfad um shadowing zu vermeiden
defer wg.Done()
//Flag, ob Filter zutrifft
toFilter := false
// Pfad für Regex vereinheitlichen (wichtig für Windows)
datname := filepath.ToSlash(p)
// Prüfen, ob das Muster /Jahr/Monat/ im Pfad vorkommt
if reDatePath.MatchString(datname) {
toFilter = true
}
//Wenn Filterung zutriff, abbrechen
if toFilter {
fmt.Println("\nausgefiltert:", datname)
return //Datei überspringen
}
//Die Pfade für Autoren und Kategorien auch nicht berücksichtigen
if strings.Contains(datname, "/author/") || strings.Contains(datname, "/category/") {
toFilter = true
}
// Wenn nicht gefiltert, verarbeiten
if toFilter{
fmt.Println("\nverarbeitet:", datname)
} else {
processHTMLFile(p, *prefix, searchDir, false)
}
}(dateiPfad)
}
return nil
})
if err != nil {
fmt.Println("Fehler:", err)
return
}
wg.Wait()
erg = saveIndex(zielpfad, *baseURL)
if *cli {
//Suche auf der Kommandozeile durchführen
for {
fmt.Print("\nEnde der Suche durch Eingabe von exit\nSuche: ")
var input string
fmt.Scanln(&input)
input = strings.TrimSpace(strings.ToLower(input))
if input == "exit" || input == "" { break }
if locations, ok := index[input]; ok {
fmt.Printf("Treffer in %d Dateien.\n", len(locations))
for _, loc := range locations { fmt.Printf(" - %s\n", loc) }
} else {
fmt.Println("Nichts gefunden.")
}
}
}
fmt.Printf("\nAnzahl=%d\n", len(index))
os.Exit(erg)
}
func processHTMLFile(datName string, prefix string, searchDir string, debug_mode bool) {
file, err := os.Open(datName)
if err != nil {
return
}
defer file.Close()
doc, err := html.Parse(file)
if err != nil {
return
}
if debug_mode {fmt.Printf("\n--- Debugging Datei: %s ---\n", datName)}
var f func(*html.Node)
f = func(n *html.Node) {
if n.Type == html.ElementNode {
// Extrahiere Klassen für das Debugging
var classes string
for _, a := range n.Attr {
if a.Key == "class" {
classes = a.Val
}
}
// OPTIONAL: Alle gefundenen Divs/Header anzeigen
if debug_mode {fmt.Printf("Node: <%s> Class: [%s]\n", n.Data, classes)}
for _, class := range allowedClasses {
if strings.Contains(classes, class) {
if debug_mode {fmt.Printf(" [TREFFER] Klasse gefunden: '%s' in <%s>\n", class, n.Data)}
text := extractText(n)
// Zeige die ersten 50 Zeichen des extrahierten Textes
preview := text
if len(preview) > 50 {
preview = preview[:50] + "..."
}
if debug_mode {fmt.Printf(" Text-Vorschau: %s\n", strings.TrimSpace(preview))}
tokenizeAndIndex(text, datName, prefix, searchDir)
return
}
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
f(c)
}
}
f(doc)
}
// Hilfsfunktion: Extrahiert allen Text unterhalb eines Knotens (ohne Tags)
func extractText(n *html.Node) string {
var b strings.Builder
var f func(*html.Node)
f = func(n *html.Node) {
if n.Type == html.TextNode {
b.WriteString(n.Data)
b.WriteString(" ")
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
f(c)
}
}
f(n)
return b.String()
}
func tokenizeAndIndex(text string, dateiPfad string, prefix string,searchDir string) {
cleanText := strings.ToLower(text)
for _, phrase := range stopPhrases {
cleanText = strings.ReplaceAll(cleanText, phrase, " ")
}
words := strings.Fields(cleanText)
mutex.Lock()
defer mutex.Unlock()
webPath := dateiPfad
webPath = prefix + strings.ReplaceAll(webPath, searchDir, "")
//Da alle Beiträge im Unterverzeichnis in der index.html stehen, diesen Dateinamen entfernen
webPath = strings.Replace(webPath, "/index.html", "", -1)
for _, w := range words {
w = strings.Trim(w, ".,:;!?()\"'<> \t\n\r*+-")
if len(w) < 3 || stopWords[w] {
continue
}
if !isAlreadyIn(index[w], webPath) {
index[w] = append(index[w], webPath)
}
}
}
func isAlreadyIn(slice []string, val string) bool {
for _, item := range slice {
if item == val { return true }
}
return false
}
func loadStopWords(datName string) {
file, err := os.Open(datName)
if err != nil {
fmt.Printf("Hinweis: %s nicht gefunden.\n", datName)
return
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(strings.ToLower(scanner.Text()))
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if strings.Contains(line, " ") || strings.Contains(line, "<") {
stopPhrases = append(stopPhrases, line)
} else {
stopWords[line] = true
}
}
}
func saveIndex(zielpfad string, baseurl string) int {
mutex.Lock()
defer mutex.Unlock()
erg := 0
//Allgemeine Variable für Zieldatei - wird mehrfach verwendete. Deshalb hier die Variable erstmal zur Verfügung stellen
zielDat := ""
// JSON speichern
if (len(index)) > 0 {
finalJSON, _ := json.MarshalIndent(index, "", " ")
zielDat = zielpfad + "index.json"
err := os.WriteFile(zielDat, finalJSON, 0644)
if err != nil {
fmt.Println("❌ Fehler beim Schreiben von",zielDat)
erg = 3
} else {
fmt.Println("✅ Datei", zielDat," wurde erfolgreich mit" , len(index), "Einträgen erstellt.")
//sitemap.xml erstellen
erg = createSitemap(zielpfad,baseurl)
}
// Jetzt noch die Wortliste erstellen
// Alle Keys (Wörter) in ein Slice sammeln
woerter := make([]string, 0, len(index))
for wort := range index {
woerter = append(woerter, wort)
}
// Alphabetisch sortieren
sort.Strings(woerter)
// Datei erstellen
zielDat = zielpfad + "wordlist.txt"
file, err := os.Create(zielDat)
defer file.Close()
if err != nil {
fmt.Println("❌ Fehler beim Schreiben von",zielDat)
erg = 3
} else {
fmt.Println("✅ Datei", zielDat," wurde erfolgreich mit" , len(woerter), " Wörtern erstellt.")
writer := bufio.NewWriter(file)
//Zeilenweise schreiben
for _, wort := range woerter {
writer.WriteString(wort + "\n")
}
//Puffer leeren
writer.Flush()
}
}
// Schalterdatei für Rsync erstellen
zielDat = zielpfad + "rsync_schalter.txt"
// alte Datei löschen - keine Fehlerabfrage notwendig
os.Remove(zielDat)
// Neue Datei nur anlegen, wenn auch Seiten zur Veröffentlichung vorliegen
if (len(index)) > 0 {
currentTime := time.Now().Format("2006-01-02 15:04:05")
fmt.Println("Zeitstempel:",currentTime)
file, err := os.Create(zielDat)
if err != nil {
fmt.Println("❌ Fehler beim Schreiben von",zielDat)
erg = 4
}
defer file.Close()
_, err = file.WriteString(currentTime + "\n")
if err != nil {
fmt.Println("❌ Fehler beim Schreiben von",zielDat)
erg = 4
} else {
fmt.Println("✅ Datei wurde erstellt.",zielDat)
}
}
//Wordliste schreiben
return erg
}
//Pfadangaben bei Bedarf am Ende um den Pfadtrenner ergänzen
func ensureTrailingSeparator(datName string) string {
sep := string(os.PathSeparator)
if !strings.HasSuffix(datName, sep) {
return datName + sep
}
return datName
}
//Sitemap aus den Daten der Suchergebnisse erzeugen
func createSitemap(zielpfad string, baseurl string) int {
// Daten - liegen bereits als map in der Variablen index vor
// Aktuelles Datum für lastmod formatieren
currentTime := time.Now().Format("2006-01-02")
// Sitemap befüllen
sitemap := URLSet{
Xmlns: "http://www.sitemaps.org/schemas/sitemap/0.9",
XmlnsXsi: "http://www.w3.org/2001/XMLSchema-instance",
XsiSchemaLoc: "http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd",
}
// Hilfs-Map, um URLs zu de-duplizieren
// Wir nutzen struct{}, weil es im Speicher 0 Byte belegt (effizienter als bool)
seen := make(map[string]struct{})
// 3. Über die Map iterieren
for _, urls := range index {
for _, url := range urls {
// Wenn URL schon gesehen, überspringen
if _, exists := seen[url]; exists {
continue
}
//nur für Testzweck
//fmt.Println("url=",url)
// Als "erledigt" markieren
seen[url] = struct{}{}
// Priorität festlegen
priority := 0.8
if url == baseurl {
priority = 1.0
}
// Zur Sitemap hinzufügen
sitemap.URLs = append(sitemap.URLs, URLEntry{
Loc: baseurl+url,
LastMod: currentTime,
ChangeFreq: "weekly",
Priority: priority,
})
}
}
// XML generieren
output, err := xml.MarshalIndent(sitemap, "", " ")
if err != nil {
fmt.Printf("❌ Fehler beim XML-Export: %v\n", err)
return 7
}
// Header hinzufügen und Datei schreiben
header := []byte(xml.Header)
finalXML := append(header, output...)
zieldat := zielpfad + "sitemap.xml"
err = os.WriteFile(zieldat, finalXML, 0644)
if err != nil {
fmt.Printf("❌ Fehler beim Schreiben der Datei: %v\n", err)
return 6
}
fmt.Println("✅ Datei", zieldat," wurde mit" , len(seen), "internen Seiten-URL erstellt.")
return 0
}
Die Bash-Datei zur Durchführugn des Build-Vorgangs:
/geany /srv/containers/wordpress/go-indexer/main.go
muss folgenden Inhalt haben:
sudo chown pi:www-data /srv/containers/wordpress/go-indexer -R
cd /srv/containers/wordpress/go-indexer
go build -o search_indexer main.go
sudo chmod +x search_indexer
sudo mv /srv/containers/wordpress/go-indexer/search_indexer /srv/containers/wordpress/wp_data/wp-content/plugins/mirror_simply/
sudo chown www-data:www-data /srv/containers/wordpress/wp_data/wp-content/plugins/mirror_simply/search_indexer
sudo ls -la /srv/containers/wordpress/wp_data/wp-content/plugins/mirror_simply/
Darin wird das ausführbare Programm erzeugt, mit den notwendigen Rechten versehen und in das Plugin-Verzeichnis unseres selbst erstellten Plugins verschoben.