| 1 |
<?php |
| 2 |
|
| 3 |
namespace WPDeveloper\BetterDocs\Core; |
| 4 |
|
| 5 |
use WPDeveloper\BetterDocs\Utils\Base; |
| 6 |
|
| 7 |
/** |
| 8 |
* Lightweight, non-blocking doc-view collection for the Free Overview. |
| 9 |
* |
| 10 |
* The frontend tracker (assets/static/public/js/analytics-tracker.js) fires a |
| 11 |
* sendBeacon after DOMContentLoaded on single docs; the REST ingest endpoint |
| 12 |
* (REST/AnalyticsTracker) calls record_view() which increments the daily |
| 13 |
* aggregate row in {prefix}betterdocs_analytics plus the per-post views meta. |
| 14 |
* |
| 15 |
* This replaces the Pro synchronous wp_head counter so collection lives in |
| 16 |
* Free and a Pro upgrade is not empty. Pro adds richer collection in a later |
| 17 |
* unit on top of the raw events table. |
| 18 |
*/ |
| 19 |
class AnalyticsTracker extends Base { |
| 20 |
/** |
| 21 |
* @var \WPDeveloper\BetterDocs\Core\Settings |
| 22 |
*/ |
| 23 |
protected $settings; |
| 24 |
|
| 25 |
public function __construct( Settings $settings ) { |
| 26 |
$this->settings = $settings; |
| 27 |
|
| 28 |
add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_tracker' ] ); |
| 29 |
} |
| 30 |
|
| 31 |
/** |
| 32 |
* Enqueue the beacon on single docs only and hand it the post id + nonce. |
| 33 |
*/ |
| 34 |
public function enqueue_tracker() { |
| 35 |
if ( ! is_singular( 'docs' ) ) { |
| 36 |
return; |
| 37 |
} |
| 38 |
|
| 39 |
$post_id = get_the_ID(); |
| 40 |
if ( ! $post_id ) { |
| 41 |
return; |
| 42 |
} |
| 43 |
|
| 44 |
$ga4_enabled = (bool) $this->settings->get( 'analytics_ga4', false ); |
| 45 |
$ga4_method = (string) $this->settings->get( 'ga4_method', 'datalayer' ); |
| 46 |
$measurement = sanitize_text_field( (string) $this->settings->get( 'ga4_measurement_id', '' ) ); |
| 47 |
|
| 48 |
betterdocs()->assets->enqueue( 'betterdocs-analytics-tracker', 'public/js/analytics-tracker.js', [], true ); |
| 49 |
betterdocs()->assets->localize( |
| 50 |
'betterdocs-analytics-tracker', |
| 51 |
'betterDocsTracker', |
| 52 |
[ |
| 53 |
'rest_url' => rest_url( 'betterdocs/v1/analytics/view' ), |
| 54 |
'nonce' => wp_create_nonce( 'wp_rest' ), |
| 55 |
'post_id' => $post_id, |
| 56 |
// GA4 forwarding config. `method` selects how events are sent |
| 57 |
// (datalayer | gtag | mp); the Measurement Protocol api_secret is |
| 58 |
// intentionally NEVER exposed here — it is read server-side only. |
| 59 |
'ga4' => [ |
| 60 |
'enabled' => $ga4_enabled, |
| 61 |
'method' => $ga4_method, |
| 62 |
'measurement_id' => ( 'gtag' === $ga4_method ) ? $measurement : '' |
| 63 |
] |
| 64 |
] |
| 65 |
); |
| 66 |
|
| 67 |
// gtag mode: load Google's gtag.js on doc pages and initialize the property |
| 68 |
// so the tracker's gtag('event', …) calls reach GA4 without external GTM. |
| 69 |
if ( $ga4_enabled && 'gtag' === $ga4_method && '' !== $measurement ) { |
| 70 |
wp_enqueue_script( |
| 71 |
'betterdocs-gtag', |
| 72 |
'https://www.googletagmanager.com/gtag/js?id=' . rawurlencode( $measurement ), |
| 73 |
[], |
| 74 |
null, |
| 75 |
false |
| 76 |
); |
| 77 |
wp_add_inline_script( |
| 78 |
'betterdocs-gtag', |
| 79 |
"window.dataLayer = window.dataLayer || [];\n" |
| 80 |
. "function gtag(){dataLayer.push(arguments);}\n" |
| 81 |
. "gtag('js', new Date());\n" |
| 82 |
. "gtag('config', '" . esc_js( $measurement ) . "');", |
| 83 |
'after' |
| 84 |
); |
| 85 |
} |
| 86 |
} |
| 87 |
|
| 88 |
/** |
| 89 |
* Increment the daily aggregate for a doc view. Write-only, fast. |
| 90 |
* |
| 91 |
* @param int $post_id Doc post id. |
| 92 |
* @param int $unique_hint 1 if the client reports this is a first view in the browser. |
| 93 |
* @return bool True when a view was recorded. |
| 94 |
*/ |
| 95 |
public function record_view( $post_id, $unique_hint = 0 ) { |
| 96 |
global $wpdb; |
| 97 |
|
| 98 |
$post_id = (int) $post_id; |
| 99 |
if ( ! $post_id || ! $this->is_eligible_visits() ) { |
| 100 |
return false; |
| 101 |
} |
| 102 |
|
| 103 |
$post = get_post( $post_id ); |
| 104 |
if ( ! $post || $post->post_type !== 'docs' || $post->post_status !== 'publish' ) { |
| 105 |
return false; |
| 106 |
} |
| 107 |
|
| 108 |
$table = $wpdb->prefix . 'betterdocs_analytics'; |
| 109 |
$today = gmdate( 'Y-m-d' ); |
| 110 |
$unique_enabled = $this->settings->get( 'unique_visitor_count' ) != false; |
| 111 |
$unique_inc = ( $unique_enabled && (int) $unique_hint === 1 ) ? 1 : 0; |
| 112 |
|
| 113 |
$row_id = $wpdb->get_var( |
| 114 |
$wpdb->prepare( |
| 115 |
"SELECT id FROM {$wpdb->prefix}betterdocs_analytics WHERE post_id = %d AND created_at = %s", |
| 116 |
$post_id, |
| 117 |
$today |
| 118 |
) |
| 119 |
); |
| 120 |
|
| 121 |
if ( $row_id ) { |
| 122 |
// Atomic increments avoid a read-modify-write race between concurrent beacons. |
| 123 |
$wpdb->query( |
| 124 |
$wpdb->prepare( |
| 125 |
"UPDATE {$wpdb->prefix}betterdocs_analytics SET impressions = impressions + 1, unique_visit = unique_visit + %d WHERE id = %d", |
| 126 |
$unique_inc, |
| 127 |
$row_id |
| 128 |
) |
| 129 |
); |
| 130 |
} else { |
| 131 |
// ON DUPLICATE KEY UPDATE makes the first-view-of-day insert idempotent: |
| 132 |
// two concurrent beacons that both miss the SELECT above collapse into a |
| 133 |
// single row (incrementing) instead of racing to create duplicates, |
| 134 |
// wherever the (post_id, created_at) UNIQUE key is present. |
| 135 |
$wpdb->query( |
| 136 |
$wpdb->prepare( |
| 137 |
"INSERT INTO {$wpdb->prefix}betterdocs_analytics ( post_id, impressions, unique_visit, created_at ) VALUES ( %d, %d, %d, %s ) |
| 138 |
ON DUPLICATE KEY UPDATE impressions = impressions + 1, unique_visit = unique_visit + VALUES( unique_visit )", |
| 139 |
$post_id, |
| 140 |
1, |
| 141 |
$unique_inc, |
| 142 |
$today |
| 143 |
) |
| 144 |
); |
| 145 |
} |
| 146 |
|
| 147 |
$views = (int) get_post_meta( $post_id, '_betterdocs_meta_views', true ); |
| 148 |
update_post_meta( $post_id, '_betterdocs_meta_views', $views + 1 ); |
| 149 |
|
| 150 |
return true; |
| 151 |
} |
| 152 |
|
| 153 |
/** |
| 154 |
* Whether the current request should be counted, per analytics_from + |
| 155 |
* exclude_bot_analytics settings. Ported from the legacy Pro counter and |
| 156 |
* corrected to use get_current_user_id(). |
| 157 |
*/ |
| 158 |
/** |
| 159 |
* Public eligibility check (analytics_from + bot exclusion) for callers like |
| 160 |
* the scroll-completion path that don't go through record_view(). |
| 161 |
*/ |
| 162 |
public function is_eligible() { |
| 163 |
return $this->is_eligible_visits(); |
| 164 |
} |
| 165 |
|
| 166 |
protected function is_eligible_visits() { |
| 167 |
$should_count = false; |
| 168 |
$analytics_from = $this->settings->get( 'analytics_from', 'everyone' ); |
| 169 |
$user_id = get_current_user_id(); |
| 170 |
|
| 171 |
switch ( $analytics_from ) { |
| 172 |
case 'everyone': |
| 173 |
$should_count = true; |
| 174 |
break; |
| 175 |
case 'guests': |
| 176 |
if ( $user_id === 0 ) { |
| 177 |
$should_count = true; |
| 178 |
} |
| 179 |
break; |
| 180 |
case 'registered_users': |
| 181 |
if ( $user_id > 0 ) { |
| 182 |
$should_count = true; |
| 183 |
} |
| 184 |
break; |
| 185 |
} |
| 186 |
|
| 187 |
if ( ! $should_count ) { |
| 188 |
return false; |
| 189 |
} |
| 190 |
|
| 191 |
if ( $this->settings->get( 'exclude_bot_analytics', true ) == 1 ) { |
| 192 |
$bots = [ 'google', 'msnbot', 'ia_archiver', 'lycos', 'jeeves', 'scooter', 'fast-webcrawler', 'slurp@inktomi', 'turnitinbot', 'technorati', 'yahoo', 'findexa', 'findlinks', 'gaisbo', 'zyborg', 'surveybot', 'bloglines', 'blogsearch', 'pubsub', 'syndic8', 'userland', 'gigabot', 'become.com', 'baiduspider', '360spider', 'spider', 'sosospider', 'yandex', |
| 193 |
// AI agents / crawlers — mirror of the Pro AiTrafficCollector catalog so a |
| 194 |
// JS-capable or spoofed AI user-agent is never double-counted as a human |
| 195 |
// view (Pro records it as an AI fetch server-side; the human path is a JS |
| 196 |
// beacon). Free must not depend on Pro, so the tokens are duplicated here. |
| 197 |
// stripos is case-insensitive, so lowercase is sufficient. |
| 198 |
'gptbot', 'chatgpt-user', 'oai-searchbot', 'claudebot', 'anthropic-ai', 'claude-user', 'claude-web', 'claude-searchbot', 'perplexitybot', 'perplexity-user', 'google-extended', 'googleother', 'applebot-extended', 'cursor/', 'copilot', 'meta-externalagent', 'meta-externalfetcher', 'facebookbot', 'youbot', 'ccbot', 'bytespider', 'duckassistbot' ]; |
| 199 |
$useragent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; |
| 200 |
if ( $useragent !== '' ) { |
| 201 |
foreach ( $bots as $lookfor ) { |
| 202 |
if ( false !== stripos( $useragent, $lookfor ) ) { |
| 203 |
return false; |
| 204 |
} |
| 205 |
} |
| 206 |
} |
| 207 |
} |
| 208 |
|
| 209 |
return $should_count; |
| 210 |
} |
| 211 |
} |
| 212 |
|