'boolean', 'default' => false, 'sanitize_callback' => 'rest_sanitize_boolean', 'show_in_rest' => false, ]); } const META_KEY = '_aumframe_post_views'; const NONCE_KEY = 'aumframe_post_views_nonce'; private array $post_types = []; public function __construct() { add_action('init', [$this, 'setup_post_types']); add_action('add_meta_boxes', [$this, 'add_views_metabox']); add_action('save_post', [$this, 'save_views_metabox']); add_action('wp_head', [$this, 'track_post_view']); add_action('wp_ajax_aumframe_update_post_views', [$this, 'ajax_update_views']); add_action('wp_ajax_nopriv_aumframe_update_post_views', [$this, 'ajax_update_views']); add_filter('manage_posts_columns', [$this, 'add_views_column']); add_action('manage_posts_custom_column', [$this, 'display_views_column'], 10, 2); add_filter('manage_edit-post_sortable_columns', [$this, 'make_views_column_sortable']); add_action('pre_get_posts', [$this, 'sort_by_views']); // Clean up user browsing history every morning add_action('aumframe_cleanup_daily_records', [$this, 'cleanup_daily_records']); if (!wp_next_scheduled('aumframe_cleanup_daily_records')) { wp_schedule_event(time(), 'daily', 'aumframe_cleanup_daily_records'); } } /** * Set supported article types */ public function setup_post_types() { $this->post_types = apply_filters('aumframe_post_views_supported_types', ['post', 'page']); // Automatically include all publicly available custom article types $custom_post_types = get_post_types([ 'public' => true, '_builtin' => false ], 'names'); $this->post_types = array_unique(array_merge($this->post_types, $custom_post_types)); } /** * Add views to Meta Box */ public function add_views_metabox() { foreach ($this->post_types as $post_type) { add_meta_box( 'aum-post-views', __('Post Views', 'aumframe'), [$this, 'render_views_metabox'], $post_type, 'side', 'default' ); } } /** * Rendering Meta Box */ public function render_views_metabox($post) { wp_nonce_field(self::NONCE_KEY, self::NONCE_KEY); $views = $this->get_post_views($post->ID); $today_views = $this->get_today_views($post->ID); $total_views = $this->get_total_views($post->ID); ?>
set_post_views($post_id, $views, true); } } /** * Track article views */ public function track_post_view() { if (!is_singular() || is_preview() || is_admin()) { return; } global $post; if (!in_array(get_post_type($post), $this->post_types)) { return; } $user_id = get_current_user_id(); if ($user_id) { $today = current_time('Y-m-d'); $viewed_date = get_user_meta($user_id, '_aumframe_viewed_date', true); $viewed_today = $viewed_date === $today ? (get_user_meta($user_id, '_aumframe_viewed_posts_today', true) ?: []) : []; if (in_array($post->ID, $viewed_today, true)) { return; } $viewed_today[] = $post->ID; update_user_meta($user_id, '_aumframe_viewed_posts_today', $viewed_today); if ($viewed_date !== $today) { update_user_meta($user_id, '_aumframe_viewed_date', $today); } } $ajax = admin_url('admin-ajax.php'); $nonce = wp_create_nonce('aumframe_update_views_ajax'); $key = 'aumframe_pv_' . $post->ID; wp_register_script('aumframe-views', false, [], AUMFRAME_VERSION, false); wp_enqueue_script('aumframe-views'); /* * The template is a nowdoc, so nothing in it is interpolated and the values are * substituted with sprintf(). A heredoc here would read the four values from PHP but * would also interpolate anything else that looks like a variable; a nowdoc containing * `` fragments substitutes nothing at all and prints them into the page, * where they are a JavaScript syntax error that no PHP log records. */ wp_add_inline_script('aumframe-views', sprintf( <<<'JS' (function () { /* Same-day de-duplication for anonymous visitors happens in the browser; the server writes nothing for them. It keeps the request count down and is not a control - the server decides what it counts. */ try { var k = %1$s; var today = new Date().toISOString().slice(0, 10); if (localStorage.getItem(k) === today) { return; } localStorage.setItem(k, today); } catch (e) { /* localStorage throws in private mode: skip de-duplication. */ } var body = new URLSearchParams({ action: 'aumframe_update_post_views', post_id: %2$d, _wpnonce: %3$s }); fetch(%4$s, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body, keepalive: true /* so a visitor closing the page immediately still counts */ }).catch(function () { /* a failed count must not affect the page */ }); })(); JS, wp_json_encode($key), (int) $post->ID, wp_json_encode($nonce), wp_json_encode($ajax) )); } /** * AJAX updates page views */ public function ajax_update_views() { /* * The nonce check stops execution on failure. * * It is not what prevents inflation: this endpoint is reachable by guests, so every * visitor legitimately holds a valid nonce and could replay it. The nonce only * establishes that the request came from a page this site served. What limits the * count is the server-side per-visitor check below. * * A page served from a cache older than the nonce lifetime carries an expired nonce, * and such a request is now rejected rather than counted. The counter under-reports on * those sites; it fails closed. */ check_ajax_referer('aumframe_update_views_ajax', '_wpnonce'); $post_id = intval($_POST['post_id'] ?? 0); if (!$post_id || !in_array(get_post_type($post_id), $this->post_types)) { wp_die('Invalid post ID', 400); } if (get_post_status($post_id) !== 'publish') { wp_die('Invalid post ID', 400); } /* * One count per visitor per post per day, decided on the server. * * The client also de-duplicates in localStorage, which keeps the request count down but * is not a control: anything a visitor stores, a visitor can clear. The transient below * is keyed by a salted hash of the address and user agent, so no address is stored, and * expires at the end of the day. */ if (!$this->claim_view($post_id)) { wp_send_json_success([ 'views' => $this->get_post_views($post_id), 'today' => $this->get_today_views($post_id), 'counted' => false, ]); } // Automatically increase browsing volume and today's browsing $current_views = $this->get_post_views($post_id); $this->set_post_views($post_id, $current_views + 1, false); // Record today's browsing volume $this->record_daily_view($post_id); wp_send_json_success([ 'views' => $this->get_post_views($post_id), 'today' => $this->get_today_views($post_id), 'total' => $this->get_total_views($post_id) ]); } /** * Claims one view for this visitor and post today. * * Returns false when this visitor has already been counted for this post today, in which * case the caller must not increment anything. * * The visitor is identified by a salted hash of the remote address and user agent. The * address itself is never stored, and the transient expires at the end of the day, so the * record cannot be used to follow a visitor beyond the de-duplication it exists for. * Logged-in users are identified by their user id instead, which is consistent across * their devices and matches what `track_post_view()` records for them. */ private function claim_view(int $post_id): bool { $user_id = get_current_user_id(); if ($user_id) { $who = 'u' . $user_id; } else { $addr = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : ''; $agent = isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : ''; if ($addr === '') { /* * No address to key on. Counting every such request would make the limit * trivial to avoid, so the request is not counted. */ return false; } $who = 'g' . wp_hash($addr . '|' . $agent); } $key = 'aumframe_pv_' . $post_id . '_' . current_time('Ymd') . '_' . md5($who); if (get_transient($key)) { return false; } set_transient($key, 1, DAY_IN_SECONDS); return true; } /** * Get article views */ public function get_post_views($post_id) { $views = get_post_meta($post_id, self::META_KEY, true); return $views ? intval($views) : 0; } /** * Set article views */ public function set_post_views($post_id, $views, $is_manual = false) { $old_views = $this->get_post_views($post_id); $views = max(0, intval($views)); update_post_meta($post_id, self::META_KEY, $views); if ($is_manual) { // Record manual update logs $this->log_manual_update($post_id, $old_views, $views, get_current_user_id()); } return $views; } /** * Get today's views */ public function get_today_views($post_id) { $today = current_time('Y-m-d'); $daily_views = get_post_meta($post_id, '_aumframe_daily_views', true) ?: []; return isset($daily_views[$today]) ? intval($daily_views[$today]) : 0; } /** * Get total views (including history) */ public function get_total_views($post_id) { $daily_views = get_post_meta($post_id, '_aumframe_daily_views', true) ?: []; // If there is no daily record, use the current view count if (empty($daily_views)) { return $this->get_post_views($post_id); } // Calculate the sum of all daily views $total = array_sum(array_map('intval', $daily_views)); return $total; } /** * Record daily views */ private function record_daily_view($post_id) { $today = current_time('Y-m-d'); $daily_views = get_post_meta($post_id, '_aumframe_daily_views', true) ?: []; if (!isset($daily_views[$today])) { $daily_views[$today] = 1; } else { $daily_views[$today] = intval($daily_views[$today]) + 1; } // Retain data from the last 365 days if (count($daily_views) > 365) { $daily_views = array_slice($daily_views, -365, 365, true); } update_post_meta($post_id, '_aumframe_daily_views', $daily_views); // Record the last update date update_post_meta($post_id, '_aumframe_last_view_date', $today); } /** * Record manual update logs */ private function log_manual_update($post_id, $old_views, $new_views, $user_id) { $log = get_post_meta($post_id, '_aumframe_views_update_log', true) ?: []; $log_entry = [ 'time' => current_time('mysql'), 'user_id' => $user_id, 'old_views' => $old_views, 'new_views' => $new_views, 'type' => 'manual', 'change' => $new_views - $old_views ]; $log[] = $log_entry; // Keep the last 50 records if (count($log) > 50) { $log = array_slice($log, -50, 50, true); } update_post_meta($post_id, '_aumframe_views_update_log', $log); } /** * Clean up daily records */ public function cleanup_daily_records() { global $wpdb; $thirty_days_ago = gmdate('Y-m-d', strtotime(current_time('Y-m-d') . ' -30 days')); $posts = $wpdb->get_col($wpdb->prepare( "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = %s", '_aumframe_daily_views' )); foreach ($posts as $post_id) { $daily_views = get_post_meta($post_id, '_aumframe_daily_views', true); if (is_array($daily_views)) { foreach ($daily_views as $date => $count) { if ($date < $thirty_days_ago) { unset($daily_views[$date]); } } if (empty($daily_views)) { delete_post_meta($post_id, '_aumframe_daily_views'); } else { update_post_meta($post_id, '_aumframe_daily_views', $daily_views); } } } // Clear all users' browsing history for today $users = $wpdb->get_col("SELECT ID FROM {$wpdb->users}"); foreach ($users as $user_id) { delete_user_meta($user_id, '_aumframe_viewed_posts_today'); } } /** * Add a view count column to the article list */ public function add_views_column($columns) { $columns['views'] = __('Views', 'aumframe'); return $columns; } /** * Display browsing volume column content */ public function display_views_column($column, $post_id) { if ($column === 'views') { $views = $this->get_post_views($post_id); echo number_format($views); } } /** * Make the browsing volume column sortable */ public function make_views_column_sortable($columns) { $columns['views'] = 'views'; return $columns; } /** * Sort by views */ public function sort_by_views($query) { if (!is_admin() || !$query->is_main_query()) { return; } if ($query->get('orderby') === 'views') { $query->set('meta_key', self::META_KEY); $query->set('orderby', 'meta_value_num'); } } /** * Increase browsing volume (external call) */ public function increment_views($post_id, $count = 1) { if (!$post_id || $count <= 0) { return false; } $current_views = $this->get_post_views($post_id); $new_views = $current_views + $count; $this->set_post_views($post_id, $new_views, false); // Record daily views $today = current_time('Y-m-d'); $daily_views = get_post_meta($post_id, '_aumframe_daily_views', true) ?: []; if (!isset($daily_views[$today])) { $daily_views[$today] = $count; } else { $daily_views[$today] = intval($daily_views[$today]) + $count; } update_post_meta($post_id, '_aumframe_daily_views', $daily_views); return $new_views; } /** * Get the most popular articles */ public function get_popular_posts($args = []) { $defaults = [ 'post_type' => 'post', 'posts_per_page' => 10, 'meta_key' => self::META_KEY, 'orderby' => 'meta_value_num', 'order' => 'DESC', 'meta_query' => [ [ 'key' => self::META_KEY, 'value' => 0, 'compare' => '>', 'type' => 'NUMERIC' ] ] ]; $args = wp_parse_args($args, $defaults); return new \WP_Query($args); } }