Files
2026-08-23 13:35:10 +00:00

453 lines
16 KiB
PHP
Raw Permalink 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.
<?php
/**
* WP Multi Modul: Benutzer-Analytics
*
* Automatisch aus wp-multi.php ausgelagert. Wird über den Loader in
* wp-multi.php eingebunden (nur wenn das WP Multi Toolkit aktiv ist).
*/
if (!defined('ABSPATH')) { exit; }
/*
* Benutzer-Analytics mit verbesserten Sicherheits-, Performance- und Benutzerfreundlichkeits-Features
*/
// global ist nötig, weil die Plugin-Datei bei der Aktivierung innerhalb einer
// Funktion eingebunden wird und $wpdb dort sonst nicht verfügbar ist.
global $wpdb;
define('WP_MULTI_ANALYTICS_TABLE', $wpdb->prefix . 'wp_multi_user_analytics');
/**
* Erstellt die Datenbanktabelle für Benutzer-Analytics.
*/
function wp_multi_create_analytics_table() {
global $wpdb;
$table_name = WP_MULTI_ANALYTICS_TABLE;
$charset_collate = $wpdb->get_charset_collate();
$sql = "CREATE TABLE $table_name (
id mediumint(9) NOT NULL AUTO_INCREMENT,
user_id bigint(20) NOT NULL,
action varchar(255) NOT NULL,
post_id bigint(20) DEFAULT NULL,
timestamp datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
INDEX idx_timestamp (timestamp),
INDEX idx_action (action)
) $charset_collate;";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta($sql);
}
register_activation_hook(WP_MULTI_FILE, 'wp_multi_create_analytics_table');
/**
* Verfolgt Benutzerinteraktionen (Kommentare und Beitragsaufrufe).
*
* @param int $user_id Benutzer-ID.
* @param string $action Aktion (z. B. 'view', 'comment').
* @param int $post_id Beitrag-ID (optional).
* @return bool Erfolg der Operation.
*/
function wp_multi_track_user_activity($user_id, $action, $post_id = null) {
global $wpdb;
$table_name = WP_MULTI_ANALYTICS_TABLE;
$user_id = absint($user_id);
$action = sanitize_text_field($action);
$post_id = $post_id ? absint($post_id) : null;
if ($action === 'view' && is_single()) {
$post_id = get_the_ID();
}
// user_id 0 ist gewollt: Die Seite bietet keinen Login, Aufrufe sind anonym.
if (!$action) {
return false;
}
return $wpdb->insert(
$table_name,
array(
'user_id' => $user_id,
'action' => $action,
'post_id' => $post_id,
),
array('%d', '%s', '%d')
);
}
/**
* Verfolgt Kommentar-Aktivitäten.
*
* @param int $comment_id Kommentar-ID.
*/
function wp_multi_comment_activity($comment_id) {
$comment = get_comment($comment_id);
$user_id = absint($comment->user_id);
if ($user_id) {
wp_multi_track_user_activity($user_id, 'comment', $comment->comment_post_ID);
}
}
add_action('comment_post', 'wp_multi_comment_activity');
/**
* Erkennt Bots und Crawler anhand des User-Agents.
*
* @return bool
*/
function wp_multi_is_bot_request() {
$user_agent = isset($_SERVER['HTTP_USER_AGENT']) ? strtolower(sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT']))) : '';
// Ohne User-Agent ist es so gut wie nie ein echter Besucher
if ($user_agent === '') {
return true;
}
$signatures = apply_filters('wp_multi_bot_signatures', [
'bot', 'crawl', 'spider', 'slurp', 'archiver', 'preview', 'monitor', 'validator',
'facebookexternalhit', 'headless', 'phantomjs', 'python', 'curl', 'wget', 'go-http',
'java/', 'okhttp', 'axios', 'scrapy', 'lighthouse', 'pingdom', 'uptime',
]);
foreach ($signatures as $signature) {
if (strpos($user_agent, $signature) !== false) {
return true;
}
}
return false;
}
/**
* Prüft, ob dieser Besucher den Beitrag gerade erst aufgerufen hat.
*
* Ohne Login gibt es keine Benutzer-ID, deshalb dient ein kurzlebiger Hash aus
* IP und User-Agent als Erkennungsmerkmal. Gespeichert wird nur der Hash, nie
* die IP selbst ein Reload zählt damit nicht mehrfach.
*
* @param int $post_id Beitrag-ID.
* @return bool True, wenn der Aufruf bereits gezählt wurde.
*/
function wp_multi_view_already_counted($post_id) {
$ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : '';
$user_agent = isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : '';
$key = 'wp_multi_view_' . md5($post_id . '|' . $ip . '|' . $user_agent . '|' . wp_salt());
if (get_transient($key)) {
return true;
}
// Sperrfrist je Besucher und Beitrag
$ttl = (int) apply_filters('wp_multi_view_throttle', 30 * MINUTE_IN_SECONDS);
set_transient($key, 1, $ttl);
return false;
}
/**
* Verfolgt Beitragsaufrufe auch von nicht eingeloggten Besuchern.
*/
function wp_multi_post_view_activity() {
if (is_admin() || !is_single() || is_feed()) {
return;
}
// Eigene Aufrufe der Redaktion verfälschen die Zahlen
if (is_user_logged_in() && current_user_can('edit_posts') && !apply_filters('wp_multi_track_editor_views', false)) {
return;
}
if (wp_multi_is_bot_request()) {
return;
}
$post_id = get_the_ID();
if (!$post_id || wp_multi_view_already_counted($post_id)) {
return;
}
// Ohne Login ist die Benutzer-ID 0 der Aufruf zählt trotzdem
wp_multi_track_user_activity(get_current_user_id(), 'view', $post_id);
}
add_action('wp_head', 'wp_multi_post_view_activity');
/**
* Baut die WHERE-Bedingung für den gewählten Zeitraum.
*
* @return array [WHERE-Fragment, Parameter]
*/
function wp_multi_analytics_date_where($start_datetime = null, $end_datetime = null) {
if (!$start_datetime) {
$start_datetime = gmdate('Y-m-d H:i:s', strtotime('-7 days'));
}
$where = 'WHERE timestamp >= %s';
$params = [$start_datetime];
if ($end_datetime) {
$where .= ' AND timestamp <= %s';
$params[] = $end_datetime;
}
return [$where, $params];
}
/**
* Ruft die Tagessummen je Aktion ab Grundlage für das Diagramm.
*
* Aggregiert in der Datenbank statt in PHP: Seit auch anonyme Aufrufe gezählt
* werden, kämen hier sonst zehntausende Einzelzeilen an.
*
* @return array Zeilen mit date, action und count.
*/
function wp_multi_fetch_analytics_totals($start_datetime = null, $end_datetime = null) {
global $wpdb;
list($where, $params) = wp_multi_analytics_date_where($start_datetime, $end_datetime);
$sql = "SELECT DATE(timestamp) AS date, action, COUNT(*) AS count
FROM " . WP_MULTI_ANALYTICS_TABLE . "
$where
GROUP BY date, action
ORDER BY date ASC";
return $wpdb->get_results($wpdb->prepare($sql, $params));
}
/**
* Ruft die jüngsten Einzelereignisse ab Grundlage für die Detailtabelle.
*
* @return array Rohe Analytics-Daten (begrenzt).
*/
function wp_multi_fetch_raw_analytics($start_datetime = null, $end_datetime = null) {
global $wpdb;
list($where, $params) = wp_multi_analytics_date_where($start_datetime, $end_datetime);
// Ohne Limit würde die Detailtabelle bei anonymem Tracking die Seite sprengen
$params[] = (int) apply_filters('wp_multi_analytics_detail_limit', 200);
$sql = "SELECT DATE(timestamp) AS date, action, post_id, 1 AS count, user_id, timestamp
FROM " . WP_MULTI_ANALYTICS_TABLE . "
$where
ORDER BY timestamp DESC
LIMIT %d";
return $wpdb->get_results($wpdb->prepare($sql, $params));
}
/**
* Verarbeitet rohe Analytics-Daten für Diagramm und Tabelle.
*
* @param array $results Rohe Analytics-Daten.
* @return array Verarbeitete Daten.
*/
function wp_multi_process_analytics_data($results, $recent = null) {
$dates = [];
$comment_counts = [];
$view_counts = [];
foreach ($results as $result) {
$date = $result->date;
if (!in_array($date, $dates)) {
$dates[] = $date;
}
if ($result->action === 'comment') {
$comment_counts[$date] = ($comment_counts[$date] ?? 0) + $result->count;
} elseif ($result->action === 'view') {
$view_counts[$date] = ($view_counts[$date] ?? 0) + $result->count;
}
}
$all_dates = [];
$datasets = ['comments' => [], 'views' => []];
for ($i = 6; $i >= 0; $i--) {
$date = date('Y-m-d', strtotime("-$i day"));
$all_dates[] = $date;
$datasets['comments'][] = $comment_counts[$date] ?? 0;
$datasets['views'][] = $view_counts[$date] ?? 0;
}
return [
'dates' => array_reverse($all_dates),
'datasets' => [
[
'label' => __('Kommentare', 'wp-multi'),
'data' => array_reverse($datasets['comments']),
'borderColor' => 'rgba(75, 192, 192, 1)',
'borderWidth' => 1,
'fill' => false,
],
[
'label' => __('Beitragsaufrufe', 'wp-multi'),
'data' => array_reverse($datasets['views']),
'borderColor' => 'rgba(153, 102, 255, 1)',
'borderWidth' => 1,
'fill' => false,
]
],
'data' => ($recent === null) ? $results : $recent
];
}
/**
* Ruft Analytics-Daten mit Caching ab.
*
* @param string $date_query Datum für die Abfrage.
* @return array Analytics-Daten.
*/
function wp_multi_get_analytics_data($start_datetime = null, $end_datetime = null) {
$cache_key = 'wp_multi_analytics_data_' . md5($start_datetime . '|' . $end_datetime);
$cached_data = get_transient($cache_key);
if ($cached_data !== false) {
return $cached_data;
}
$totals = wp_multi_fetch_analytics_totals($start_datetime, $end_datetime);
$recent = wp_multi_fetch_raw_analytics($start_datetime, $end_datetime);
$data = wp_multi_process_analytics_data($totals, $recent);
set_transient($cache_key, $data, HOUR_IN_SECONDS);
return $data;
}
/**
* Zeigt die Benutzer-Analytics-Seite im Admin-Bereich an.
*/
function wp_multi_display_user_analytics() {
global $wpdb;
if (!$wpdb->get_var("SHOW TABLES LIKE '" . WP_MULTI_ANALYTICS_TABLE . "'")) {
echo '<div class="error"><p>' . esc_html__('Die Analytics-Tabelle existiert nicht. Bitte aktiviere das Plugin erneut.', 'wp-multi') . '</p></div>';
return;
}
$time_range = isset($_GET['time_range']) ? sanitize_text_field($_GET['time_range']) : '7days';
$start_datetime = gmdate('Y-m-d H:i:s', strtotime('-7 days'));
$end_datetime = null;
if ($time_range === '30days') {
$start_datetime = gmdate('Y-m-d H:i:s', strtotime('-30 days'));
} elseif ($time_range === 'custom' && isset($_GET['start_date'], $_GET['end_date'])) {
$custom_start = sanitize_text_field(wp_unslash($_GET['start_date']));
$custom_end = sanitize_text_field(wp_unslash($_GET['end_date']));
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $custom_start) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $custom_end)) {
$start_datetime = $custom_start . ' 00:00:00';
$end_datetime = $custom_end . ' 23:59:59';
}
}
$results = wp_multi_get_analytics_data($start_datetime, $end_datetime);
wpmt_admin_page_open(
__('Benutzer Analytics', 'wp-multi'),
__('Beitragsaufrufe und Kommentar-Aktivität deiner Besucher', 'wp-multi')
);
wpmt_admin_card_open(__('Zeitraum', 'wp-multi'));
?>
<form method="get">
<input type="hidden" name="page" value="wp_multi_analytics">
<select name="time_range" onchange="this.form.submit()" style="max-width:240px;">
<option value="7days" <?php selected($time_range, '7days'); ?>><?php esc_html_e('Letzte 7 Tage', 'wp-multi'); ?></option>
<option value="30days" <?php selected($time_range, '30days'); ?>><?php esc_html_e('Letzte 30 Tage', 'wp-multi'); ?></option>
<option value="custom" <?php selected($time_range, 'custom'); ?>><?php esc_html_e('Benutzerdefiniert', 'wp-multi'); ?></option>
</select>
<?php if ($time_range === 'custom') : ?>
<input type="date" name="start_date" value="<?php echo esc_attr($_GET['start_date'] ?? ''); ?>" style="max-width:180px;">
<input type="date" name="end_date" value="<?php echo esc_attr($_GET['end_date'] ?? ''); ?>" style="max-width:180px;">
<?php endif; ?>
<button type="submit" class="wpm-btn wpm-btn--primary"><?php esc_html_e('Filtern', 'wp-multi'); ?></button>
</form>
<?php
wpmt_admin_card_close();
if (empty($results['data'])) {
wpmt_admin_notice('info', __('Keine Daten für den gewählten Zeitraum verfügbar.', 'wp-multi'));
} else {
wpmt_admin_card_open(__('Aktivitätsverlauf', 'wp-multi'));
?>
<canvas id="userActivityChart" style="height: 300px; width: 100%;"></canvas>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
if (typeof Chart !== 'undefined') {
document.addEventListener('DOMContentLoaded', function () {
var ctx = document.getElementById('userActivityChart').getContext('2d');
var chart = new Chart(ctx, {
type: 'line',
data: {
labels: <?php echo wp_json_encode($results['dates']); ?>,
datasets: <?php echo wp_json_encode($results['datasets']); ?>,
},
options: {
scales: {
x: { title: { display: true, text: '<?php echo esc_js(__('Datum', 'wp-multi')); ?>' } },
y: { title: { display: true, text: '<?php echo esc_js(__('Anzahl', 'wp-multi')); ?>' }, beginAtZero: true }
}
}
});
});
} else {
console.error('Chart.js konnte nicht geladen werden.');
}
</script>
<?php
wpmt_admin_card_close();
wpmt_admin_card_open(__('Einzelne Aktivitäten', 'wp-multi'));
?>
<div class="wpm-table-wrap">
<table class="wpm-table">
<thead>
<tr>
<th><?php esc_html_e('Benutzer ID', 'wp-multi'); ?></th>
<th><?php esc_html_e('Aktion', 'wp-multi'); ?></th>
<th><?php esc_html_e('Beitrag Titel', 'wp-multi'); ?></th>
<th><?php esc_html_e('Beitrag ID', 'wp-multi'); ?></th>
<th><?php esc_html_e('Zeitstempel', 'wp-multi'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($results['data'] as $row) : ?>
<tr>
<td><?php echo $row->user_id ? esc_html($row->user_id) : esc_html__('Gast', 'wp-multi'); ?></td>
<td><span class="wpm-badge <?php echo $row->action === 'comment' ? 'wpm-badge--success' : ''; ?>"><?php echo esc_html($row->action); ?></span></td>
<td>
<?php
if ($row->post_id) {
$post_title = get_the_title($row->post_id);
echo esc_html($post_title ?: __('Kein Titel verfügbar', 'wp-multi'));
} else {
echo esc_html__('Kein Beitrag', 'wp-multi');
}
?>
</td>
<td><?php echo esc_html($row->post_id ?: '-'); ?></td>
<td><?php echo esc_html($row->timestamp); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php
wpmt_admin_card_close();
}
wpmt_admin_page_close();
}
// Der Analytics-Menüpunkt wird zentral in wp_multi_register_admin_menus() registriert.