PluginProbe
SEO Engine – Smart SEO with AI, Schema & Redirection for WordPress / 0.9.4
SEO Engine – Smart SEO with AI, Schema & Redirection for WordPress v0.9.4
0.9.4 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 0.8.6 0.8.5 0.8.4 0.8.2 0.8.1 0.7.9 0.8.0 0.7.7 0.7.8 0.7.6 0.7.5 0.7.4 0.7.3 0.7.2 0.7.1 0.7.0 0.6.5 All 88 releases
seo-engine / classes / core.php

core.php in SEO Engine – Smart SEO with AI, Schema & Redirection for WordPress 0.9.4, at classes/core.php

2,940 lines 90.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 class Meow_MWSEO_Core
4 {
5 public $admin = null;
6 public $is_rest = false;
7 public $site_url = null;
8 public $pro = null; // Premium features
9
10 // TODO: Migrate to _mwseo_title
11 public $meta_key_seo_title = '_kiss_seo_title';
12 // TODO: Migrate to _mwseo_excerpt
13 public $meta_key_seo_excerpt = '_kiss_seo_excerpt';
14 // TODO: Migrate to _mwseo_ignore
15 public $meta_key_skip = '_kiss_seo_ignore';
16 public $meta_key_seo_keywords = '_mwseo_keywords';
17
18 private $old_option_name = 'seo_kiss_options';
19 private $option_name = 'mwseo_options';
20
21 private $sitemap_module = null;
22 private $schema_module = null;
23 private $insights_module = null;
24 private $analytics_module = null;
25 private $googleanalytics_module = null;
26 private $plausibleanalytics_module = null;
27 private $matomoanalytics_module = null;
28 private $parsers = null;
29 public $redirects_module = null;
30 public $indexnow_module = null;
31
32 public function __construct() {
33 global $mwseo_core;
34 $mwseo_core = $this;
35
36 $this->site_url = get_site_url();
37 $this->is_rest = MeowKit_MWSEO_Helpers::is_rest();
38
39 add_action( 'plugins_loaded', array( $this, 'init' ) );
40
41 $analyze_on_update = $this->get_option( 'analyze_on_update', 'quick' );
42 if ( $analyze_on_update !== 'none' && $analyze_on_update !== false ) {
43 add_action( 'save_post', array( $this, 'analyze_on_update' ), 10, 3 );
44 }
45 // Always registered so events scheduled before a settings change still run.
46 add_action( 'mwseo_analyze_post_event', array( $this, 'run_scheduled_analysis' ), 10, 2 );
47 }
48
49 function init() {
50 global $mwseo;
51
52 $mwseo = new Meow_MWSEO_API( $this );
53
54 // Part of the core, settings and stuff
55 $this->admin = new Meow_MWSEO_Admin( $this );
56
57 // Only for REST
58 if ( $this->is_rest ) {
59 new Meow_MWSEO_Rest( $this, $this->admin );
60 }
61
62 // Attachment URL redirection feature
63 $redirect_attachments = $this->get_option( 'redirect_attachments', false );
64 if ( $redirect_attachments ) {
65 add_filter( 'attachment_link', array( $this, 'redirect_attachment_to_parent' ), 10, 2 );
66 add_action( 'template_redirect', array( $this, 'handle_attachment_redirect' ) );
67 }
68
69 // Dashboard
70 $use_mwseo_sitemap = $this->get_option( 'sitemap', false );
71 if ( $use_mwseo_sitemap ) {
72 add_filter(
73 'init',
74 function() {
75 $this->sitemap_module = new Meow_MWSEO_Sitemap( $this );
76 wp_register_sitemap_provider( MWSEO_DOMAIN, $this->sitemap_module );
77 }
78 );
79
80 }
81
82 // Website
83 //add_filter( 'wp_title', [ $this, 'render_title' ], 10, 2 );
84 $use_content_seo = $this->get_option( 'content_seo', false );
85 $use_technical_seo = $this->get_option( 'technical_seo', false );
86 $use_seo_metadata = $this->get_option( 'use_seo_metadata', true );
87 $auto_page_title = $this->get_option( 'auto_page_title', true );
88 // When another SEO plugin (Yoast, Rank Math, AIOSEO, SEOPress) is handling the
89 // frontend meta tags, SEO Engine steps back so the two don't emit duplicates.
90 // It keeps doing everything else (analysis, scoring, AI, MCP) regardless.
91 $render_frontend_meta = $this->should_render_frontend_meta();
92 if ( ( $use_content_seo || $use_technical_seo ) && $auto_page_title && $render_frontend_meta ) {
93 add_filter( 'pre_get_document_title', [ $this, 'render_title' ], 99, 1 );
94 // Remove WordPress archive prefixes ("Archives: ", "Category: ", etc.) for cleaner SEO titles
95 add_filter( 'get_the_archive_title_prefix', '__return_empty_string' );
96 }
97 if ( $use_content_seo && $use_seo_metadata && $render_frontend_meta ) {
98 add_action( 'wp_head', [ $this, 'render_description' ], 99, 0 );
99 add_action( 'wp_head', [ $this, 'render_canonical' ], 99, 0 );
100 }
101
102 add_action( 'init', [ $this, 'reject_gptbot_user_agent' ], 99, 0 );
103
104 // Live content: when enabled, the analyzed content is the actual rendered page
105 // (handles all page builders) instead of the raw post_content.
106 if ( $this->get_live_content_mode() !== 'disabled' ) {
107 add_filter( 'mwseo_post_content', [ $this, 'filter_live_content' ], 10, 3 );
108 } else {
109 // If not using Live Content, rely on parsers instead
110 $this->parsers = new Meow_MWSEO_Helpers_Parsers( $this );
111 }
112
113 // Advanced Core
114 if ( class_exists( 'MeowPro_MWSEO_Core' ) ) {
115 $this->pro = new MeowPro_MWSEO_Core( $this );
116 }
117
118 // Insights
119 if ( class_exists( 'Meow_MWSEO_Modules_Insights' ) ) {
120 $this->insights_module = new Meow_MWSEO_Modules_Insights( $this );
121 }
122
123 // Schema
124 if ( class_exists( 'Meow_MWSEO_Modules_Schema' ) ) {
125 $this->schema_module = new Meow_MWSEO_Modules_Schema( $this );
126 }
127
128 // Analytics
129 if ( class_exists( 'Meow_MWSEO_Modules_Analytics' ) ) {
130 $this->analytics_module = new Meow_MWSEO_Modules_Analytics( $this );
131
132 // Track visits on frontend
133 if ( !is_admin() && !$this->is_rest ) {
134 add_action( 'wp', array( $this, 'track_current_visit' ) );
135 }
136 }
137
138 // Google Analytics
139 if ( class_exists( 'Meow_MWSEO_Modules_GoogleAnalytics' ) ) {
140 $this->googleanalytics_module = new Meow_MWSEO_Modules_GoogleAnalytics( $this );
141 }
142
143 // Plausible Analytics
144 if ( class_exists( 'Meow_MWSEO_Modules_PlausibleAnalytics' ) ) {
145 $this->plausibleanalytics_module = new Meow_MWSEO_Modules_PlausibleAnalytics( $this );
146 }
147
148 // Matomo Analytics
149 if ( class_exists( 'Meow_MWSEO_Modules_MatomoAnalytics' ) ) {
150 $this->matomoanalytics_module = new Meow_MWSEO_Modules_MatomoAnalytics( $this );
151 }
152
153 // Redirects + 404 monitor
154 if ( class_exists( 'Meow_MWSEO_Modules_Redirects' ) ) {
155 $this->redirects_module = new Meow_MWSEO_Modules_Redirects( $this );
156 }
157
158 // IndexNow (instant indexing pings on publish/update)
159 if ( class_exists( 'Meow_MWSEO_Modules_IndexNow' ) ) {
160 $this->indexnow_module = new Meow_MWSEO_Modules_IndexNow( $this );
161 }
162
163 // MCP integration - check both class and global variable
164 if ( class_exists( 'Meow_MWAI_Core' ) || isset( $GLOBALS['mwai'] ) ) {
165 new Meow_MWSEO_MCP( $this );
166 }
167
168 // AI Engine integration: fills the SEO block in AI Engine's Modules tab.
169 // The filter is harmless when AI Engine is absent, so no gating needed.
170 new Meow_MWSEO_Modules_AIEngine( $this );
171
172
173 }
174
175 function get_logs() {
176 $log_file_path = $this->get_logs_path();
177
178 if ( !file_exists( $log_file_path ) ) {
179 return "Empty log file.";
180 }
181
182 $content = file_get_contents( $log_file_path );
183 // $lines = explode( "\n", $content );
184 // $lines = array_filter( $lines );
185 // $lines = array_reverse( $lines );
186 // $content = implode( "\n", $lines );
187 return $content;
188 }
189
190 function clear_logs() {
191 $logPath = $this->get_logs_path();
192 if ( file_exists( $logPath ) ) {
193 unlink( $logPath );
194 }
195
196 $options = $this->get_all_options();
197 $options['logs_path'] = null;
198 $this->update_options( $options );
199 }
200
201 function get_logs_path() {
202 $uploads_dir = wp_upload_dir();
203 $uploads_dir_path = trailingslashit( $uploads_dir['basedir'] );
204
205 $path = $this->get_option( 'logs_path' );
206
207 if ( $path && file_exists( $path ) ) {
208 // make sure the path is legal (within the uploads directory with the MWSEO_PREFIX and log extension)
209 if ( strpos( $path, $uploads_dir_path ) !== 0 || strpos( $path, MWSEO_PREFIX ) === false || substr( $path, -4 ) !== '.log' ) {
210 $path = null;
211 } else {
212 return $path;
213 }
214 }
215
216 if ( !$path ) {
217 $path = $uploads_dir_path . MWSEO_PREFIX . "_" . $this->random_ascii_chars() . ".log";
218 if ( !file_exists( $path ) ) {
219 touch( $path );
220 }
221 $options = $this->get_all_options();
222 $options['logs_path'] = $path;
223 $this->update_options( $options );
224 }
225
226 return $path;
227 }
228
229 private function random_ascii_chars( $length = 10 ) {
230 $chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
231 $chars_length = strlen( $chars );
232 $random_string = '';
233 for ( $i = 0; $i < $length; $i++ ) {
234 $random_string .= $chars[ rand( 0, $chars_length - 1 ) ];
235 }
236 return $random_string;
237 }
238
239 function log( $data = null ) {
240 $enabled = $this->get_option( 'logs', false );
241 if ( !$enabled ) { return; }
242
243 $log_file_path = $this->get_logs_path();
244
245 // Check log file size and rotate if too large (1MB limit)
246 if ( file_exists( $log_file_path ) ) {
247 $file_size = filesize( $log_file_path );
248 $max_size = apply_filters( 'mwseo_max_log_size', 1 * 1024 * 1024 ); // 1MB default
249
250 if ( $file_size > $max_size ) {
251 @unlink( $log_file_path );
252 // Log the rotation event
253 $fh = @fopen( $log_file_path, 'w' );
254 if ( $fh ) {
255 $date = date( "Y-m-d H:i:s" );
256 fwrite( $fh, "$date: 🔄 Log file rotated (exceeded " . size_format( $max_size ) . ")\n\n" );
257 fclose( $fh );
258 }
259 }
260 }
261
262 // Make sure the log file exists before opening it to avoid warnings
263 if ( !file_exists( $log_file_path ) ) {
264 @touch( $log_file_path );
265 }
266
267 $fh = @fopen( $log_file_path, 'a' );
268 if ( !$fh ) { return false; }
269 $date = date( "Y-m-d H:i:s" );
270 if ( is_null( $data ) ) {
271 fwrite( $fh, "\n" );
272 }
273 else {
274 fwrite( $fh, "$date: {$data}\n" );
275 //error_log( "[SEO ENGINE] $data" );
276 }
277 fclose( $fh );
278 return true;
279 }
280
281 function get_funny_rejection_message() {
282 $messages = [
283 "🤖 Human detected! Pretending to be GPTBot? That’s a fun role-reversal!",
284 "😂 Nice try, human! Even robots like GPTBot get a day off, courtesy of <a href='https://wordpress.org/plugins/seo-engine/'>SEO Engine</a>.",
285 "🎭 Humans playing robots? What a twist! GPTBot access: Denied by <a href='https://wordpress.org/plugins/seo-engine/'>SEO Engine</a>!",
286 "👀 Spotted! A human camouflaging as GPTBot. <a href='https://wordpress.org/plugins/seo-engine/'>SEO Engine</a> sees you!",
287 "😜 A human mimicking GPTBot? That's some advanced <a href='https://wordpress.org/plugins/seo-engine/'>SEO Engine</a> wizardry!",
288 "🤣 Oh, human, GPTBot impressions? You’re fun, but <a href='https://wordpress.org/plugins/seo-engine/'>SEO Engine</a> says no entry!",
289 "🤠 Yeehaw! A wild human pretending to be GPTBot? Not on <a href='https://wordpress.org/plugins/seo-engine/'>SEO Engine</a>'s watch!",
290 "🕵️‍♂️ Detective human, trying to sneak in as GPTBot? <a href='https://wordpress.org/plugins/seo-engine/'>SEO Engine</a> caught ya!",
291 "🤪 Human, are you trying to robot? Denied access by the watchful <a href='https://wordpress.org/plugins/seo-engine/'>SEO Engine</a>!",
292 "🚫 You must be a fun human to mimic GPTBot! But, <a href='https://wordpress.org/plugins/seo-engine/'>SEO Engine</a> keeps the door locked.",
293 "�
294 Humans doing robot things? Not under <a href='https://wordpress.org/plugins/seo-engine/'>SEO Engine</a>’s cyber-watch!",
295 "🤨 A human in robot’s clothing! Nice try, but <a href='https://wordpress.org/plugins/seo-engine/'>SEO Engine</a> is not fooled.",
296 "🤔 Hmm, human, your GPTBot disguise needs work. Spotted by <a href='https://wordpress.org/plugins/seo-engine/'>SEO Engine</a>!",
297 "😆 The human is a robot? This plot twist brought to you by <a href='https://wordpress.org/plugins/seo-engine/'>SEO Engine</a>!",
298 "🧐 An insightful human trying to GPTBot around! But, <a href='https://wordpress.org/plugins/seo-engine/'>SEO Engine</a> is on guard.",
299 "🎉 Surprise! Human, you can’t sneak past <a href='https://wordpress.org/plugins/seo-engine/'>SEO Engine</a>, even dressed as GPTBot!",
300 "🎲 Rolled the dice, human? GPTBot disguise didn’t work on <a href='https://wordpress.org/plugins/seo-engine/'>SEO Engine</a>.",
301 "🤥 Human, pretending to be GPTBot? <a href='https://wordpress.org/plugins/seo-engine/'>SEO Engine</a> knows your secret!",
302 "🧛‍♂️ Ah ah ah, human! Even as GPTBot, you can’t bypass the <a href='https://wordpress.org/plugins/seo-engine/'>SEO Engine</a> barrier!",
303 "🤓 Nerdy human alert! GPTBot cosplay isn’t fooling <a href='https://wordpress.org/plugins/seo-engine/'>SEO Engine</a>!"
304 ];
305 return $messages[ array_rand( $messages ) ];
306 }
307
308
309 function reject_gptbot_user_agent()
310 {
311 $disallow_gpt_bot = $this->get_option( 'disallow_gpt_bot', false );
312
313 if ( $disallow_gpt_bot && isset($_SERVER[ 'HTTP_USER_AGENT' ]) && strpos( $_SERVER[ 'HTTP_USER_AGENT' ], 'GPTBot' ) !== false ) {
314 $this->log( '🤖 GPTBot has been detected and blocked.');
315 header( 'HTTP/1.1 403 Forbidden' );
316 exit( $this->get_funny_rejection_message() );
317 }
318 }
319
320 function get_post_types() {
321 // global $wpdb;
322 // $post_types = $wpdb->get_col( "SELECT DISTINCT post_type FROM $wpdb->posts" );
323 $post_types = get_post_types( array( 'public' => true ), 'names', 'and' );
324
325 // Exclude post types that don't need SEO
326 $excluded_types = array( 'revision', 'wp_global_styles', 'wp_navigation' );
327 $post_types = array_diff( $post_types, $excluded_types );
328
329 return array_values( $post_types );
330 }
331
332 function get_categories() {
333
334 $taxonomy = apply_filters( 'mwseo_category_taxonomies', array( 'category', 'product_cat' ) );
335
336 $categories = get_categories( array(
337 'taxonomy' => $taxonomy,
338 'hide_empty' => false,
339 ) );
340
341 return $categories;
342 }
343
344 // Make post type list as [ 'post_type_value' => 'post_type_label' ]
345 function make_post_type_list( $post_types ) {
346 $list = [];
347 foreach ( $post_types as $post_type ) {
348 $posttype_obj = get_post_type_object( $post_type );
349 if ( empty( $posttype_obj ) ) {continue;}
350 $label = get_post_type_object( $post_type )->label;
351 if ( !$label ) {continue;}
352
353 $list[$post_type] = $label;
354 }
355 return $list;
356 }
357
358 function make_category_list( $categories ) {
359 $list = [];
360 foreach ( $categories as $category ) {
361 $list[$category->term_id] = $category;
362 }
363 return array_values( $list );
364 }
365
366 function get_taxonomies() {
367 $taxonomies = get_taxonomies( array( 'public' => true ), 'objects' );
368 $taxonomies = array_filter( $taxonomies, function( $taxonomy ) {
369 return !in_array( $taxonomy->name, [ 'nav_menu', 'link_category', 'post_format' ] );
370 } );
371 return $taxonomies;
372 }
373
374 function make_taxonomy_list( $taxonomies ) {
375 $list = [];
376 foreach ( $taxonomies as $taxonomy ) {
377 $list[$taxonomy->name] = $taxonomy->label;
378 }
379 return $list;
380 }
381
382 function check_title_duplicates($title, $id) {
383 $duplicate_hashes = get_option( 'mwseo_title_hashes' );
384 if ( empty( $duplicate_hashes ) ) {
385 $duplicate_hashes = array();
386 }
387
388 $title_hash = md5( $title );
389
390 if ( array_key_exists( $title_hash, $duplicate_hashes ) ) {
391 if( $duplicate_hashes[ $title_hash ] == $id ) {
392 return false;
393 }
394 else {
395 //verify that the post still exists
396 $post = get_post( $duplicate_hashes[ $title_hash ] );
397 if( empty( $post ) || $post->post_status == 'trash') {
398 unset( $duplicate_hashes[ $title_hash ] );
399 update_option( 'mwseo_title_hashes', $duplicate_hashes );
400 return false;
401 }
402
403 return true;
404 }
405 }
406 else {
407 $duplicate_hashes[ $title_hash ] = $id;
408 update_option( 'mwseo_title_hashes', $duplicate_hashes );
409 return false;
410 }
411 }
412
413 // Keeps scores and issue counts fresh after edits: instead of analyzing inline (which
414 // would slow every save, badly so in full mode with AI checks), schedule a one-off cron
415 // event a few seconds later. Only re-analyzes posts that already have an analysis, so a
416 // brand new draft is never surprise-scanned.
417 function analyze_on_update( $post_id, $post, $update ) {
418 if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) return;
419 if ( !$post || $post->post_status !== 'publish' ) return;
420 $public_types = get_post_types( array( 'public' => true ) );
421 if ( !in_array( $post->post_type, $public_types, true ) ) return;
422 if ( empty( get_post_meta( $post_id, '_mwseo_analysis', true ) ) ) return;
423
424 $analyze_on_update = $this->get_option( 'analyze_on_update', 'quick' );
425 $analysis_type = ( $analyze_on_update === 'full' ) ? 'full' : 'quick';
426 if ( !wp_next_scheduled( 'mwseo_analyze_post_event', array( $post_id, $analysis_type ) ) ) {
427 wp_schedule_single_event( time() + 10, 'mwseo_analyze_post_event', array( $post_id, $analysis_type ) );
428 }
429 }
430
431 function run_scheduled_analysis( $post_id, $analysis_type = 'quick' ) {
432 $post = get_post( $post_id );
433 if ( !$post || $post->post_status !== 'publish' ) return;
434 $this->calculate_seo_score( $post, $analysis_type === 'full' ? 'full' : 'quick' );
435 }
436
437
438 // Decides whether or not the post is SEO-friendly or not.
439 function calculate_seo_score( $post, $analysis_type = 'full' ) {
440 global $mwseo_score;
441 if ( !$mwseo_score ) {
442 return [ 'status' => 'error', 'message' => 'Score module not initialized.' ];
443 }
444
445 if ( get_post_meta( $post->ID, '_mwseo_status', true ) === 'skip' ) {
446 $this->log( "🚫 Post (#{$post->ID}) was skipped." );
447 return [ 'status' => 'skip', 'message' => 'Skipped.' ];
448 }
449
450 // Clear magic fix markers before re-analysis
451 $this->clear_magic_fix_markers( $post->ID );
452
453 // Calculate SEO score with analysis type ('quick' or 'full')
454 $result = $mwseo_score->calculate( $post, $analysis_type );
455
456 // Save results
457 update_post_meta( $post->ID, '_mwseo_analysis', $result );
458
459 // Also save denormalized meta for sorting/filtering
460 update_post_meta( $post->ID, '_mwseo_overall', $result['overall'] );
461 // Note: cq and tech are legacy pillar scores, removed in v3
462 update_post_meta( $post->ID, '_mwseo_updated', time() );
463
464 // Maintain backward compatibility with old meta keys
465 $status = $this->get_status_from_score( $result );
466 $message = $this->get_message_from_score( $result );
467 $codes = $this->get_codes_from_score( $result );
468
469 update_post_meta( $post->ID, '_mwseo_score', $result['overall'] );
470 update_post_meta( $post->ID, '_mwseo_status', $status );
471 update_post_meta( $post->ID, '_mwseo_message', $message );
472 update_post_meta( $post->ID, '_mwseo_codes', $codes );
473
474 return [
475 'score' => $result['overall'],
476 'status' => $status,
477 'message' => $message,
478 'errors' => [ 'codes' => $codes ],
479 'data' => $result
480 ];
481 }
482
483 // Convert score results to legacy status
484 private function get_status_from_score( $result ) {
485 $overall = $result['overall'];
486
487 if ( $overall >= 70 ) return 'ok';
488 if ( $overall >= 40 ) return 'error'; // Could improve
489 return 'error'; // Needs attention
490 }
491
492 // Convert score results to legacy message
493 private function get_message_from_score( $result ) {
494 $overall = $result['overall'];
495
496 if ( $overall >= 70 ) {
497 $emoji = '🟢';
498 $label = __( 'Great', 'seo-engine' );
499 } elseif ( $overall >= 40 ) {
500 $emoji = '🟠';
501 $label = __( 'Could improve', 'seo-engine' );
502 } else {
503 $emoji = '🔴';
504 $label = __( 'Needs attention', 'seo-engine' );
505 }
506
507 $message = sprintf( '%s %s (%d%%)', $emoji, $label, $overall );
508
509 if ( !empty( $result['ai']['summary'] ) ) {
510 $message .= "\n" . sprintf( __( 'How AI reads this: "%s"', 'seo-engine' ), $result['ai']['summary'] );
511 }
512
513 return $message;
514 }
515
516 // Extract error codes from score tests
517 private function get_codes_from_score( $result ) {
518 $codes = [];
519
520 foreach ( $result['tests'] as $test_name => $score ) {
521 if ( $score === 'NA' ) continue;
522 if ( $score < 70 ) {
523 $codes[] = $test_name;
524 }
525 }
526
527 return $codes;
528 }
529
530 function get_all_posts_with_seo_score() {
531
532 // $show_unscored_posts = $this->get_option( 'show_unscored_chart', false );
533 // $show_any_post_type = $this->get_option( 'show_any_post_type', false );
534
535 $show_any_post_type = false;
536 $show_unscored_posts = true;
537
538 $meta_query = $show_unscored_posts ? [] : [ 'key' => '_mwseo_score', 'compare' => 'EXISTS' ];
539 $default_post_type = $this->get_option( 'default_post_type', 'post' );
540 $post_type = $show_any_post_type ? 'any' : $default_post_type;
541
542 // When 'any' is selected, use only the enabled post types from settings
543 if ( $post_type === 'any' ) {
544 $post_type = $this->get_option( 'select_post_types', ['post', 'page'] );
545 }
546
547 // PERFORMANCE FIX: Allow limiting posts for very large sites
548 // Default to 10000 max to prevent memory issues
549 $max_posts = apply_filters( 'seo_engine_max_chart_posts', 10000 );
550
551 $args = array(
552 'post_type' => $post_type,
553 'posts_per_page' => $max_posts,
554 'meta_query' => array(
555 $meta_query,
556 ),
557 );
558
559 $posts = get_posts( $args );
560
561 // PERFORMANCE FIX: Prime meta cache to avoid N+1 queries
562 if (!empty($posts)) {
563 $post_ids = wp_list_pluck($posts, 'ID');
564 update_meta_cache('post', $post_ids);
565 }
566
567 $posts_with_seo_score = array();
568
569 foreach ( $posts as $post ) {
570 $seo_score = get_post_meta( $post->ID, '_mwseo_score', true );
571 $seo_status = get_post_meta( $post->ID, '_mwseo_status', true );
572
573
574 $posts_with_seo_score[] = array(
575 'id' => $post->ID,
576 'title' => $post->post_title,
577 'score' => $seo_score,
578 'status' => $seo_status,
579 'category' => $this->get_seo_category( $seo_score ),
580 );
581 }
582
583 return $posts_with_seo_score;
584 }
585
586 private function get_seo_category ( $score ) {
587
588 if ( $score >= 0 && $score < 25 ) {
589 return 'Terrible';
590 }
591 else if ( $score >= 25 && $score < 50 ) {
592 return 'Poor';
593 }
594 else if ( $score >= 50 && $score < 75 ) {
595 return 'Acceptable';
596 }
597 else if ( $score >= 75 && $score < 90 ) {
598 return 'Great';
599 }
600 else if ( $score >= 90 && $score <= 100 ) {
601 return 'Excellent';
602 }
603 else {
604 return 'Unknown';
605 }
606 }
607
608 function get_seo_engine_post_meta( $post ) {
609
610 $post_id = $post->ID;
611
612 $score = get_post_meta( $post_id, '_mwseo_score', true );
613 $status = get_post_meta( $post_id, '_mwseo_status', true );
614 $message = get_post_meta( $post_id, '_mwseo_message', true );
615 $codes = get_post_meta( $post_id, '_mwseo_codes', true );
616
617 if( !$status ) { $status = 'pending'; }
618 if( !$message ) { $message = 'Start an analysis to get a score.'; }
619
620 return [
621 'score' => $score,
622 'status' => $status,
623 'message' => $message,
624 'codes' => $codes,
625 ];
626 }
627
628
629
630 function build_excerpt( $post, $excerpt = "" ) {
631 if ( is_category() ) {
632 $category = get_queried_object();
633 if ( $category && !empty( $category->term_id ) ) {
634 $seo_description = get_term_meta( $category->term_id, '_mwseo_description', true );
635 $excerpt = !empty( $seo_description ) ? $seo_description : category_description();
636 } else {
637 $excerpt = category_description();
638 }
639 }
640 else if ( is_tag() ) {
641 $excerpt = tag_description();
642 }
643 else if ( is_author() ) {
644 $excerpt = get_the_author_meta( 'description' );
645 }
646 else if ( is_search() ) {
647 $excerpt = get_search_query();
648 }
649 else if ( is_404() ) {
650 $excerpt = '404';
651 }
652 else if ( is_home() ) {
653 $excerpt = get_the_excerpt( get_option( 'page_for_posts' ) );
654 }
655 else if ( is_archive() ) {
656 $excerpt = get_the_archive_description();
657 }
658 else if ( !empty( $post ) ) {
659 $seo_excerpt = get_post_meta( $post->ID, $this->meta_key_seo_excerpt, true );
660 $excerpt = !empty( $seo_excerpt ) ? $seo_excerpt : get_the_excerpt( $post->ID );
661 }
662 $excerpt = html_entity_decode( $excerpt );
663 return $excerpt;
664 }
665
666 function build_title( $post, $title = "" ) {
667 $override = false;
668 if ( is_category() ) {
669 $category = get_queried_object();
670 if ( $category && !empty( $category->term_id ) ) {
671 $seo_title = get_term_meta( $category->term_id, '_mwseo_title', true );
672 if ( !empty( $seo_title ) ) {
673 $override = true;
674 $title = $seo_title;
675 } else {
676 $title = single_cat_title( '', false );
677 }
678 } else {
679 $title = single_cat_title( '', false );
680 }
681 }
682 else if ( is_tag() ) {
683 $title = single_tag_title( '', false );
684 }
685 else if ( is_author() ) {
686 $title = get_the_author();
687 }
688 else if ( is_search() ) {
689 $title = get_search_query();
690 }
691 else if ( is_404() ) {
692 $title = '404';
693 }
694 else if ( is_home() ) {
695 $title = get_the_title( get_option( 'page_for_posts' ) );
696 }
697 else if ( is_archive() ) {
698 $title = get_the_archive_title();
699 }
700 else if ( !empty( $post ) ) {
701 $seo_title = get_post_meta( $post->ID, $this->meta_key_seo_title, true );
702 if ( !empty( $seo_title ) ) {
703 $override = true;
704 $title = $seo_title;
705 }
706 else {
707 $title = get_the_title( $post->ID );
708 }
709 }
710 $title = trim( strip_tags( $title ) );
711 if ( !$override ) {
712 $title = $title . " | " . trim( get_bloginfo( 'name' ) );
713 }
714 $title = html_entity_decode( $title );
715 return $title;
716 }
717
718 // Kept in sync with the detection surfaced to the admin UI in admin.php.
719 function is_other_seo_plugin_active() {
720 return class_exists( 'WPSEO_Frontend' ) // Yoast SEO
721 || class_exists( 'All_in_One_SEO_Pack' ) // All in One SEO
722 || class_exists( 'RankMath' ) // Rank Math
723 || class_exists( 'SEOPress' ); // SEOPress
724 }
725
726 // Whether SEO Engine should output its own frontend meta tags (title, description,
727 // canonical, Open Graph). In 'auto' it steps back when another SEO plugin is active
728 // so the two don't emit duplicate tags; 'always' and 'never' force the behavior.
729 // The analysis, scoring, AI and MCP features run regardless of this setting.
730 function should_render_frontend_meta() {
731 $mode = $this->get_option( 'frontend_meta_mode', 'auto' );
732 if ( $mode === 'always' ) {
733 return true;
734 }
735 if ( $mode === 'never' ) {
736 return false;
737 }
738 return !$this->is_other_seo_plugin_active();
739 }
740
741 function render_title( $title ) {
742 global $post;
743 $title = $this->build_title( $post, $title );
744 return esc_html( $title );
745 }
746
747 function render_description() {
748 global $post;
749 $excerpt = $this->build_excerpt( $post );
750 $excerpt = trim( strip_tags( $excerpt ) );
751 echo '<meta class="mwseo-meta" name="description" content="' . esc_html( $excerpt ) . '" />';
752 }
753
754 function render_canonical() {
755 global $post;
756 if ( ! $post instanceof WP_Post ) {
757 return;
758 }
759 $canonical = get_post_meta( $post->ID, '_mwseo_canonical', true );
760 if ( !empty( $canonical ) ) {
761 echo '<link class="mwseo-canonical" rel="canonical" href="' . esc_url( $canonical ) . '" />';
762 }
763 }
764
765 function get_images_from_content( $content ) {
766 if (empty($content)) {
767 return array();
768 }
769
770 $dom = new DOMDocument;
771 $loadHTML = @$dom->loadHTML($content);
772
773 if (!$loadHTML) {
774 throw new Exception('Failed to load HTML content');
775 }
776
777 $images = array();
778
779 $images_html = array();
780 foreach ( $dom->getElementsByTagName( 'img' ) as $img ) {
781 $src = $img->getAttribute( 'src' );
782 $alt = $img->getAttribute( 'alt' );
783
784 if ( $src && filter_var( $src, FILTER_VALIDATE_URL ) ) {
785 $images_html[] = array(
786 'src' => preg_replace( '~-[0-9]+x[0-9]+.~', '.', $src ),
787 'alt' => $alt ? $alt : '',
788 );
789 }
790 }
791
792 $images_shortcode = array();
793 $pattern = get_shortcode_regex();
794 preg_match_all( '/'. $pattern .'/s', $content, $matches );
795 if ( !empty( $matches[2] ) ) {
796 foreach ( $matches[2] as $key => $shortcode ) {
797 if ( $shortcode == 'gallery' || $shortcode == 'meow-gallery' ) {
798 $atts = shortcode_parse_atts( $matches[3][$key] );
799 if (isset($atts['ids'])) {
800 $ids = explode( ',', $atts['ids'] );
801 foreach ( $ids as $id ) {
802 if (is_numeric($id)) {
803 $src = wp_get_attachment_url( $id );
804 $alt = get_post_meta( $id, '_wp_attachment_image_alt', true );
805 if ($src && filter_var($src, FILTER_VALIDATE_URL)) {
806
807 if( in_array( $src, array_column( $images_shortcode, 'src' ) ) ) {
808 continue;
809 }
810
811 $images_shortcode[] = array(
812 'src' => $src,
813 'alt' => $alt ? $alt : '',
814 );
815 }
816 }
817 }
818 }
819 }
820 }
821 }
822
823 $images = array_merge( $images_html, $images_shortcode );
824
825 return $images;
826 }
827
828 function get_images_from_content_with_above_paragraph( $img_src_array, $content ) {
829 $images = [];
830
831 //Get all the text before the img src
832 foreach ( $img_src_array as $img_src ) {
833
834 $before_img = substr( $content, 0, strpos( $content, $img_src ) );
835 $before_img = substr( $before_img, strrpos( $before_img, '<p>' ) + 3 );
836 $before_img = strip_tags( $before_img );
837 $before_img = preg_replace( '/\s+/', ' ', $before_img );
838 $before_img = trim( $before_img );
839
840 $images[] = array(
841 'src' => $img_src,
842 'alt' => $before_img,
843 'text' => $before_img,
844 );
845 }
846
847
848 return $images;
849 }
850
851 // Normalizes the live content mode option, accounting for legacy boolean values
852 // ('disabled' = off, 'main' = extract main content, 'whole' = use the whole page).
853 function get_live_content_mode() {
854 $mode = $this->get_option( 'check_live_content', 'disabled' );
855 if ( $mode === true || $mode === 1 || $mode === '1' ) {
856 return 'main';
857 }
858 if ( in_array( $mode, [ 'main', 'whole' ], true ) ) {
859 return $mode;
860 }
861 return 'disabled';
862 }
863
864 function filter_live_content( $content, $post, $strip_html = true ) {
865 return $this->get_live_content( $post, $strip_html );
866 }
867
868 function get_live_content( $post, $strip_html = true ) {
869 // Fetch the actual rendered page (handles all page builders)
870 $post_url = get_permalink( $post->ID );
871 if ( !$post_url ) {
872 $this->log( "⚠️ Live Content: Could not find an URL to load for post {$post->ID}" );
873 return $strip_html ? wp_strip_all_tags( $post->post_content ) : $post->post_content;
874 }
875
876 $response = wp_remote_get( $post_url, [
877 'timeout' => 15,
878 'user-agent' => 'Mozilla/5.0 (compatible; SEO Engine Content Analyzer; +' . home_url() . ')',
879 'sslverify' => false, // Allow self-signed certs on local/staging
880 ] );
881
882 if ( is_wp_error( $response ) ) {
883 $this->log( "⚠️ Live Content: HTTP request failed for {$post_url}. Error: " . $response->get_error_message() );
884 return $strip_html ? wp_strip_all_tags( $post->post_content ) : $post->post_content;
885 }
886
887 $status_code = wp_remote_retrieve_response_code( $response );
888 if ( $status_code !== 200 ) {
889 $this->log( "⚠️ Live Content: HTTP {$status_code} for {$post_url}" );
890 return $strip_html ? wp_strip_all_tags( $post->post_content ) : $post->post_content;
891 }
892
893 $html = wp_remote_retrieve_body( $response );
894 if ( empty( $html ) ) {
895 $this->log( "⚠️ Live Content: Empty response body for {$post_url}" );
896 return $strip_html ? wp_strip_all_tags( $post->post_content ) : $post->post_content;
897 }
898
899 // Extract main content area, unless the whole page should be analyzed.
900 $content = $this->get_live_content_mode() === 'whole' ? $html : $this->extract_main_content( $html );
901 $content_length = strlen( wp_strip_all_tags( $content ) );
902
903 if ( $content_length < 50 ) {
904 $this->log( "⚠️ Live Content: Extracted only {$content_length} chars from {$post_url}" );
905 } else {
906 $this->log( "�
907 Live Content: Extracted {$content_length} chars from {$post_url}" );
908 }
909
910 if ( $strip_html ) {
911 $content = wp_strip_all_tags( $content );
912 }
913
914 return preg_replace( '/\s+/', ' ', trim( $content ) );
915 }
916
917 /**
918 * Extract main content from rendered HTML page
919 */
920 private function extract_main_content( $html ) {
921 // Remove scripts, styles, comments
922 $html = preg_replace( '/<script\b[^>]*>.*?<\/script>/is', '', $html );
923 $html = preg_replace( '/<style\b[^>]*>.*?<\/style>/is', '', $html );
924 $html = preg_replace( '/<!--.*?-->/s', '', $html );
925
926 // Try common content selectors
927 $selectors = [
928 '/<article[^>]*>(.*?)<\/article>/is',
929 '/<main[^>]*>(.*?)<\/main>/is',
930 '/<div[^>]*class="[^"]*(?:entry-content|post-content|page-content|content-area)[^"]*"[^>]*>(.*?)<\/div>/is',
931 ];
932
933 foreach ( $selectors as $pattern ) {
934 if ( preg_match( $pattern, $html, $m ) && strlen( wp_strip_all_tags( $m[1] ) ) > 100 ) {
935 return $m[1];
936 }
937 }
938
939 // Fallback: body minus header/footer/nav
940 if ( preg_match( '/<body[^>]*>(.*?)<\/body>/is', $html, $m ) ) {
941 $body = $m[1];
942 $body = preg_replace( '/<header[^>]*>.*?<\/header>/is', '', $body );
943 $body = preg_replace( '/<footer[^>]*>.*?<\/footer>/is', '', $body );
944 $body = preg_replace( '/<nav[^>]*>.*?<\/nav>/is', '', $body );
945 $body = preg_replace( '/<aside[^>]*>.*?<\/aside>/is', '', $body );
946 return $body;
947 }
948
949 return $html;
950 }
951
952 function check_images_alt_text( $post ) {
953
954 $content = apply_filters( 'mwseo_post_content', $post->post_content, $post, false );
955
956 $images = $this->get_images_from_content( $content );
957 if ( !is_array( $images ) ) {
958 return array();
959 }
960
961 $missing_alt_text = array();
962 $image_meta_data = $this->get_all_image_meta_data($images);
963
964 foreach ( $images as $image ) {
965 if ( empty( $image[ 'alt' ] ) ) {
966 $attachment_id = attachment_url_to_postid( $image[ 'src' ] );
967 $meta_alt_text = $image_meta_data[$attachment_id];
968 if ( !empty( $meta_alt_text ) ) {
969 $this->log( sprintf( "🖼 Image (src : %s) has alt text : %s", $image[ 'src' ], $meta_alt_text ) );
970 continue;
971 }
972 $missing_alt_text[] = $image[ 'src' ];
973 }
974 }
975
976 return $missing_alt_text;
977 }
978
979 function get_all_image_meta_data($images) {
980 $meta_data = array();
981 foreach ($images as $image) {
982 $attachment_id = attachment_url_to_postid( $image[ 'src' ] );
983 $meta_data[$attachment_id] = get_post_meta( $attachment_id, '_wp_attachment_image_alt', true );
984 }
985 return $meta_data;
986 }
987
988
989 function check_links( $content ){
990
991 $links = array();
992 $external_links = array();
993 $internal_links = array();
994
995 if ( preg_match_all( '/<a[^>]+>/i', $content, $matches ) ) {
996 foreach ( $matches[0] as $link ) {
997 if ( preg_match( '/href="http[s]?:\/\/([^"]+)"/i', $link, $href ) ) {
998 $external_links[] = $href[ 1 ];
999 }elseif ( preg_match( '/href="([^"]+)"/i', $link, $href ) ) {
1000 $internal_links[] = $href[ 1 ];
1001 }
1002 }
1003 }
1004
1005 //TODO: Display the links somewhere, for now just the count
1006
1007 $links[ 'external' ] = count( $external_links );
1008 $links[ 'internal' ] = count( $internal_links );
1009 $links[ 'total' ] = $links[ 'external' ] + $links[ 'internal' ];
1010
1011 return $links;
1012 }
1013
1014
1015 /**
1016 * Generate AI solution for a specific issue - Backward compatibility wrapper
1017 *
1018 * @deprecated Use $core->pro->magic_fix->generate_solution() instead
1019 */
1020 public function magic_fix_generate_solution( $post, $issue_type ) {
1021 if ( $this->pro && $this->pro->magic_fix ) {
1022 return $this->pro->magic_fix->generate_solution( $post, $issue_type );
1023 }
1024 $this->log( '⚠️ Magic Fix is not available. This is a premium feature.' );
1025 return false;
1026 }
1027
1028 /**
1029 * Apply a generated solution to a post - Backward compatibility wrapper
1030 *
1031 * @deprecated Use $core->pro->magic_fix->apply_solution() instead
1032 */
1033 public function magic_fix_apply_solution( $post, $issue_type, $solution ) {
1034 if ( $this->pro && $this->pro->magic_fix ) {
1035 return $this->pro->magic_fix->apply_solution( $post, $issue_type, $solution );
1036 }
1037 $this->log( '⚠️ Magic Fix is not available. This is a premium feature.' );
1038 return false;
1039 }
1040
1041 /**
1042 * Mark an issue as fixed by Magic Fix - Backward compatibility wrapper
1043 *
1044 * @deprecated Use $core->pro->magic_fix->mark_as_fixed() instead
1045 */
1046 public function mark_issue_as_magic_fixed( $post_id, $issue_type ) {
1047 if ( $this->pro && $this->pro->magic_fix ) {
1048 return $this->pro->magic_fix->mark_as_fixed( $post_id, $issue_type );
1049 }
1050 }
1051
1052 /**
1053 * Check if an issue has been marked as fixed - Backward compatibility wrapper
1054 *
1055 * @deprecated Use $core->pro->magic_fix->is_fixed() instead
1056 */
1057 public function is_issue_magic_fixed( $post_id, $issue_type ) {
1058 if ( $this->pro && $this->pro->magic_fix ) {
1059 return $this->pro->magic_fix->is_fixed( $post_id, $issue_type );
1060 }
1061 return false;
1062 }
1063
1064 /**
1065 * Clear all magic fix markers for a post - Backward compatibility wrapper
1066 *
1067 * @deprecated Use $core->pro->magic_fix->clear_markers() instead
1068 */
1069 public function clear_magic_fix_markers( $post_id ) {
1070 if ( $this->pro && $this->pro->magic_fix ) {
1071 return $this->pro->magic_fix->clear_markers( $post_id );
1072 }
1073 }
1074
1075 /**
1076 * Migrate old meta keys to new mwseo prefixed keys
1077 * This migration runs once per post when the post is accessed/analyzed
1078 */
1079 public function migrate_post_meta_keys( $post_id ) {
1080 // Mapping of old keys to new keys
1081 $meta_keys_map = [
1082 '_seo_engine_data' => '_mwseo_analysis',
1083 '_seo_engine_ignored_tests' => '_mwseo_ignored_tests',
1084 '_seo_engine_overall' => '_mwseo_overall',
1085 '_seo_engine_updated' => '_mwseo_updated',
1086 '_seo_engine_ai_keywords' => '_mwseo_keywords',
1087 '_seo_title' => '_mwseo_title',
1088 '_seo_excerpt' => '_mwseo_excerpt',
1089 '_seo_robots' => '_mwseo_robots',
1090 '_seo_canonical' => '_mwseo_canonical',
1091 '_seo_status' => '_mwseo_status',
1092 '_seo_message' => '_mwseo_message',
1093 '_seo_score' => '_mwseo_score',
1094 '_seo_codes' => '_mwseo_codes',
1095 ];
1096
1097 foreach ( $meta_keys_map as $old_key => $new_key ) {
1098 $value = get_post_meta( $post_id, $old_key, true );
1099 if ( $value !== '' && $value !== false ) {
1100 // Copy to new key
1101 update_post_meta( $post_id, $new_key, $value );
1102 // Delete old key
1103 delete_post_meta( $post_id, $old_key );
1104 }
1105 }
1106 }
1107
1108 /**
1109 *
1110 * Roles & Access Rights
1111 *
1112 */
1113 function can_access_settings() {
1114 return apply_filters( 'mwseo_allow_setup', current_user_can( 'manage_options' ) );
1115 }
1116
1117 function can_access_features() {
1118 return apply_filters( 'mwseo_allow_usage', current_user_can( 'administrator' ) );
1119 }
1120
1121 #region Options
1122
1123 function get_option( $option, $default = null ) {
1124 $options = $this->get_all_options();
1125 return $options[$option] ?? $default;
1126 }
1127
1128 function sanitized_options(){
1129 // This is a READ-ONLY method - it computes derived values but NEVER writes to the database
1130 $options = $this->get_all_options();
1131
1132 $options['post_types'] = $this->make_post_type_list( $this->get_post_types() );
1133 $options['categories'] = $this->make_category_list( $this->get_categories() );
1134
1135 if ( $options['language'] == 'auto' || empty( $options['language'] ) ) {
1136 $options['language'] = $this->get_language_name( get_locale() ) ?? 'English';
1137 }
1138
1139 if ( empty( $options['readability_treshold'] ) ) {
1140 $options['readability_treshold'] = 50;
1141 }
1142
1143 // Always force analyze_buffer to 1 - analyzing multiple posts at once causes performance issues
1144 $options['analyze_buffer'] = 1;
1145
1146 // AI Engine - check status but DON'T modify stored options
1147 global $mwai;
1148
1149 $options['mwai_has_ai'] = !empty( $mwai ) && $mwai->hasAI();
1150 $options['mwai_has_mcp'] = !empty( $mwai ) && $mwai->hasMCP();
1151 // Legacy
1152 $options['ai_engine_status'] = $options['mwai_has_ai'];
1153
1154 // AI Features - Only disable in the returned array for UI purposes
1155 // DO NOT modify the stored user preferences
1156 if ( !$options['mwai_has_ai'] ) {
1157 $options['ai_magic_fix'] = false;
1158 $options['ai_auto_correct'] = false;
1159 $options['ai_magic_wand'] = false;
1160 $options['ai_keywords'] = false;
1161 $options['woocommerce_assistant'] = false;
1162 }
1163
1164 // Module hierarchy - disable child features when parent module is disabled
1165 // Technical SEO children
1166 if ( empty( $options['technical_seo'] ) ) {
1167 $options['auto_schema_enabled'] = false;
1168 $options['redirect_attachments'] = false;
1169 $options['disallow_gpt_bot'] = false;
1170 $options['sitemap'] = false;
1171 $options['social_networks'] = false;
1172 $options['robot_editor'] = false;
1173 $options['llms_editor'] = false;
1174 $options['indexnow'] = false;
1175 }
1176
1177 // Return sanitized options WITHOUT persisting to database
1178 return $options;
1179 }
1180
1181 // TODO: Review which options are still in use after meta key refactoring - many may be obsolete
1182 function list_options() {
1183 return array(
1184 // Module Toggles
1185 'content_seo' => true,
1186 'technical_seo' => true,
1187
1188 'speed_and_vitals' => false,
1189 'analytics' => true,
1190
1191 // Content SEO
1192 'default_post_type' => 'post',
1193 'posts_limit' => 10,
1194 'posts_sort' => array( 'accessor' => 'score', 'by' => 'asc' ),
1195 'posts_filter' => 'all',
1196 'post_types' => $this->make_post_type_list( $this->get_post_types() ),
1197
1198 'select_post_types' => ['post', 'page'],
1199 'readability_treshold' => 50,
1200 'language' => $this->get_language_name( get_locale() ) ?? 'English',
1201 // 'quick' keeps scores fresh after edits (non-AI baseline, scheduled off-request);
1202 // 'full' also re-runs the AI checks; 'none' lets counts go stale until a manual scan.
1203 'analyze_on_update' => 'quick',
1204 'analyze_buffer' => 1,
1205 'analyze_buffer_interval' => 0,
1206
1207 // Technical SEO
1208 'indexnow' => true,
1209 'indexnow_key' => '',
1210 'sitemap' => false,
1211 'disable_wp_sitemap' => false,
1212 'sitemap_custom' => false,
1213 'sitemap_style' => 'default',
1214 'sitemap_path' => '',
1215 'sitemap_exclude_users_provider' => false,
1216 'sitemap_exclude_posts_provider' => false,
1217 'sitemap_exclude_taxonomies_provider' => false,
1218 'sitemap_excluded_post_types' => [],
1219 'sitemap_excluded_taxonomies' => [],
1220 'sitemap_excluded_post_ids' => [],
1221 'sitemap_max_urls' => 100,
1222 'sitemap_post_max_pages' => 100,
1223 'taxonomies' => $this->make_taxonomy_list( $this->get_taxonomies() ),
1224 'categories' => $this->make_category_list( $this->get_categories() ),
1225 'robot_editor' => false,
1226 'disallow_gpt_bot' => false,
1227
1228 // Social Networks
1229 'social_networks' => false,
1230 'social_networks_twitter' => '',
1231 'social_networks_facebook_app_id' => '',
1232
1233 // Google Cloud / API (shared: used by Speed & Vitals / PageSpeed Insights)
1234 'google_api_key' => '',
1235
1236 // Analytics Configuration
1237 'analytics_method' => 'none', // 'none', 'private', 'google', 'plausible', 'matomo'
1238
1239 // Analytics Options
1240 'bots_track' => true,
1241 'bots_instructions' => false,
1242 'bots_instructions_content' => '',
1243 // Private Analytics
1244 'general_analytics' => false,
1245 'analytics_track_logged_users' => false,
1246 'analytics_track_power_users' => false,
1247 'analytics_privacy' => false,
1248
1249 // Google Analytics
1250 // The Settings toggle and the GA module both use 'analytics_cache'; this default
1251 // was named 'google_analytics_cache' for a while, so caching silently never engaged.
1252 'analytics_cache' => true,
1253 'google_analytics' => false,
1254 'google_analytics_property_ids' => [],
1255 'google_analytics_property_id' => '',
1256 'google_analytics_client_secret' => '',
1257 'google_analytics_client_id' => '',
1258 'google_analytics_tracking_ids' => [],
1259 'google_analytics_track_logged_users' => false,
1260 'google_analytics_track_power_users' => false,
1261 'google_analytics_tracking_disabled' => false,
1262
1263 // Plausible Analytics
1264 'plausible_analytics' => false,
1265 'plausible_site_id' => '',
1266 'plausible_api_key' => '',
1267 'plausible_server_url' => 'https://plausible.io',
1268 'plausible_track_logged_users' => false,
1269 'plausible_track_power_users' => false,
1270 'plausible_analytics_tracking_disabled' => false,
1271
1272 // Matomo Analytics
1273 'matomo_analytics' => false,
1274 'matomo_site_id' => '',
1275 'matomo_api_key' => '',
1276 'matomo_server_url' => '',
1277 'matomo_track_logged_users' => false,
1278 'matomo_track_power_users' => false,
1279 'matomo_analytics_tracking_disabled' => false,
1280
1281 // Search Console module (Pro). When enabled, surfaces its own tab in
1282 // the admin with quick wins, top queries/pages, and post pulse — pulling
1283 // real Google search data. Reuses google_analytics_client_id/secret since
1284 // a single GCP OAuth client can grant tokens for multiple Google APIs.
1285 // `property` is the default for site-wide tools; `properties` is the set
1286 // of all GSC properties belonging to this WP install (Polylang/WPML
1287 // multi-domain). Per-post tools auto-pick from `properties` by URL match.
1288 'search_console' => false,
1289 'google_search_console_property' => '',
1290 'google_search_console_properties' => [],
1291
1292 // AI Features
1293 'mwai_has_ai' => false,
1294 'mwai_has_mcp' => false,
1295 'ai_engine_status' => false,
1296 'ai_magic_fix' => false,
1297 'magic_fix_instructions' => '',
1298 'ai_auto_correct' => false,
1299 'ai_magic_wand' => false,
1300 'ai_keywords' => false,
1301 'ai_semantic_analysis' => true, // Enable AI semantic analysis (requires AI Engine)
1302 'full_analysis' => true, // Enable Full Analysis with Intelligence checks
1303 'daily_insights' => true,
1304 'woocommerce_assistant' => false,
1305 'woo_assistant_instructions' => '',
1306 'select_magic_fix' => [
1307 'readability_score',
1308 ],
1309
1310 // Scoring Preferences
1311 'pillar_weight_cq' => 50, // Content Quality weight (0-100)
1312 'pillar_weight_tech' => 50, // Technical weight (0-100)
1313 'quality_safeguards_enabled' => true,
1314 'use_seo_metadata' => true,
1315 // Frontend meta output: 'auto' defers to another active SEO plugin (Yoast,
1316 // Rank Math, AIOSEO, SEOPress), 'always' outputs regardless, 'never' stays silent.
1317 'frontend_meta_mode' => 'auto',
1318 'show_ai_summary' => true,
1319 'row_density' => 'cozy', // compact | cozy
1320
1321 // Content Quality - Basic
1322 'use_excerpt_as_meta' => true,
1323 'aim_easy_reading' => true,
1324 'encourage_image_alt' => true,
1325 'ai_detect_intent' => true,
1326 'short_strong_mode' => true,
1327
1328 // Content Quality - Advanced
1329 'readability_flesch_min' => 50,
1330 'readability_flesch_max' => 70,
1331 'readability_grade_min' => 7,
1332 'readability_grade_max' => 10,
1333 'short_strong_sensitivity' => 'normal', // normal | strict | off
1334 'show_author_visible' => true,
1335 'link_to_author_page' => true,
1336 'intent_schema_coherence' => true,
1337
1338 // Technical - Basic
1339 'require_unique_title' => true,
1340 'encourage_short_slug' => true,
1341 'encourage_internal_links' => true,
1342 'check_internal_links' => true,
1343 'check_missing_alt_text' => true,
1344 'check_orphaned_content' => true,
1345 'auto_schema_enabled' => false,
1346
1347 // Length Guidelines
1348 'title_length_min' => 30,
1349 'title_length_max' => 75,
1350 'excerpt_length_min' => 80,
1351 'excerpt_length_max' => 160,
1352 'content_length_min' => 300,
1353 'content_length_max' => 1600,
1354
1355 // Technical - Advanced
1356 'slug_length_max' => 60,
1357 'slug_words_min' => 2,
1358 'slug_words_max' => 6,
1359 'smart_internal_links' => true,
1360 'check_external_links' => false,
1361 'check_live_content' => 'disabled',
1362 'schema_by_post_type' => [
1363 'post' => 'Article',
1364 'page' => 'WebPage',
1365 'product' => 'Product',
1366 ],
1367
1368 // Content Comfort Zones
1369 'comfort_zone_quick_min' => 150,
1370 'comfort_zone_quick_max' => 500,
1371 'comfort_zone_guide_min' => 700,
1372 'comfort_zone_guide_max' => 1600,
1373 'comfort_zone_product_min' => 300,
1374 'comfort_zone_product_max' => 900,
1375
1376 // Legacy Score Factors (kept for compatibility)
1377 'score_check_title_exists' => true,
1378 'score_check_title_unique' => true,
1379 'score_check_title_length' => true,
1380 'score_check_excerpt_exists' => true,
1381 'score_check_excerpt_length' => true,
1382 'score_check_slug_length' => true,
1383 'score_check_slug_words' => true,
1384 'score_check_content_length' => true,
1385 'score_check_images_alt' => true,
1386 'score_check_links' => true,
1387 'score_check_readability' => true,
1388
1389 // Redirections + 404
1390 'redirects_enabled' => false,
1391 'redirects_track_404' => true,
1392 'redirects_auto_slug' => false,
1393 'redirects_default_status' => 301,
1394 'redirects_404_retention_days' => 30,
1395 'redirects_404_exclude' => "/wp-admin/*\n/feed*\n/xmlrpc.php",
1396 'redirects_skip_bots' => true,
1397
1398 // AI Visibility
1399 'ai_visibility' => false,
1400 'ai_visibility_surfaces' => [],
1401
1402 // Maintenance & Others
1403 'logs' => false,
1404 'hide_dashboard_message' => false,
1405 'mcp_support' => false,
1406 );
1407 }
1408
1409 // TODO: Delete after January 2026
1410 function migrate_option_names( $options ) {
1411 // Create a new array with migrated keys
1412 $migrated_options = array();
1413
1414 foreach ( $options as $key => $value ) {
1415 // Check if the key starts with 'seo_engine_'
1416 if ( strpos( $key, 'seo_engine_' ) === 0 ) {
1417 // Remove 'seo_engine_' prefix
1418 $new_key = substr( $key, 11 );
1419 $migrated_options[$new_key] = $value;
1420 } else {
1421 // Keep the key as is if it doesn't have the prefix
1422 $migrated_options[$key] = $value;
1423 }
1424 }
1425
1426 // Save the migrated options
1427 update_option( $this->option_name, $migrated_options, false );
1428
1429 // Also migrate WordPress native options
1430 $this->migrate_native_options();
1431
1432 return $migrated_options;
1433 }
1434
1435 // TODO: Delete after January 2026
1436 function migrate_native_options() {
1437 // Migrate title hashes
1438 $old_title_hashes = get_option( 'seo_engine_title_hashes' );
1439 if ( $old_title_hashes !== false ) {
1440 update_option( 'mwseo_title_hashes', $old_title_hashes );
1441 delete_option( 'seo_engine_title_hashes' );
1442 }
1443
1444 // Add any other native options that need migration here
1445 }
1446
1447 function get_all_options() {
1448
1449 // TODO: Delete after January 2026
1450 $old_options = get_option( $this->old_option_name, null );
1451 if ( $old_options ) {
1452 // Migrate old options to new option name
1453 update_option( $this->option_name, $old_options, false );
1454 delete_option( $this->old_option_name );
1455
1456 $options = $old_options;
1457 } else {
1458 $options = get_option( $this->option_name, null );
1459 }
1460
1461 // Check if we need to migrate from old option names
1462 if ( $options && isset( $options['default_post_type'] ) ) {
1463 $options = $this->migrate_option_names( $options );
1464 }
1465
1466 // Make sure every option is set
1467 $options = wp_parse_args( $options, $this->list_options() );
1468
1469 if( $this->parsers ) {
1470 $options = $this->parsers->refresh_parsers( $options );
1471 }
1472
1473 return $options;
1474 }
1475
1476 function update_options( $options ) {
1477 if ( $options == get_option( $this->option_name ) ) {
1478 return $options;
1479 }
1480
1481 if ( !update_option( $this->option_name, $options, false ) ) {
1482 return false;
1483 }
1484
1485 $options = $this->sanitized_options();
1486
1487 return $options;
1488 }
1489
1490 function update_option( $option, $value ) {
1491 $options = $this->get_all_options();
1492 $options[$option] = $value;
1493 return $this->update_options( $options );
1494 }
1495
1496 function reset_options() {
1497 return $this->update_options( $this->list_options() );
1498 }
1499
1500
1501
1502 #endregion
1503
1504 #region WooCommerce
1505
1506 function generate_woocommerce_fields( $params )
1507 {
1508 global $mwai;
1509
1510 if ( is_null( $mwai ) || !isset( $mwai ) ) {
1511 throw new Exception( 'AI Engine is not available.' );
1512 }
1513
1514 $product_id = intval( $params['post_id'] ?? 0 );
1515 $product = get_post( $product_id );
1516 if ( !$product || $product->post_type !== 'product' ) {
1517 throw new Exception( 'Product not found.' );
1518 }
1519
1520 // Which fields to generate. Defaults to all of them.
1521 $allowed_fields = [ 'seo_title', 'description', 'short_description', 'tags' ];
1522 $fields = !empty( $params['fields'] ) && is_array( $params['fields'] )
1523 ? array_values( array_intersect( $allowed_fields, $params['fields'] ) )
1524 : $allowed_fields;
1525 if ( empty( $fields ) ) {
1526 throw new Exception( 'No fields requested.' );
1527 }
1528
1529 // In bulk mode we only rely on the product title (and the image via Vision),
1530 // not on the existing descriptions/attributes.
1531 $title_only = !empty( $params['title_only'] );
1532 if ( $title_only ) {
1533 $product_data = [
1534 'id' => $product_id,
1535 'title' => $product->post_title,
1536 ];
1537 }
1538 else {
1539 $wc_product = function_exists( 'wc_get_product' ) ? wc_get_product( $product_id ) : null;
1540 $product_data = [
1541 'id' => $product_id,
1542 'title' => $product->post_title,
1543 'description' => $product->post_content,
1544 'short_description' => $product->post_excerpt,
1545 'categories' => wp_get_post_terms( $product_id, 'product_cat', array( 'fields' => 'names' ) ),
1546 'tags' => wp_get_post_terms( $product_id, 'product_tag', array( 'fields' => 'names' ) ),
1547 'attributes' => $wc_product ? $wc_product->get_attributes() : [],
1548 'price' => $wc_product ? $wc_product->get_price() : null,
1549 ];
1550 }
1551
1552 // Use Vision if enabled
1553 $use_vision = !empty( $params['vision'] );
1554 $vision_result = null;
1555 if ( $use_vision ) {
1556 $product_image = get_post_meta( $product_id, '_thumbnail_id', true );
1557 if ( $product_image ) {
1558
1559 $prompt = "Describe the main elements in the image, the colors, the style, the context, and any text that is visible. Write it in " . $params['language'] . ".";
1560
1561 $intermediate = image_get_intermediate_size( $product_image, 'medium' );
1562 $path = $intermediate
1563 ? path_join( dirname( get_attached_file( $product_image ) ), $intermediate['file'] )
1564 : get_attached_file( $product_image );
1565 $url = $intermediate
1566 ? wp_get_attachment_image_url( $product_image, 'medium' )
1567 : wp_get_attachment_url( $product_image );
1568
1569 if ( !file_exists( $path ) ) {
1570 $this->log( "⚠️ WooCommerce Assistant: Product image file not found at {$path}" );
1571 } else {
1572 // Cache vision results by file hash so the same image is only described once.
1573 $file_hash = md5_file( $path );
1574 $transient_key = 'mwseo_vision_' . $file_hash;
1575 $cached = get_transient( $transient_key );
1576
1577 if ( $cached !== false ) {
1578 $this->log( "🔍 WooCommerce Assistant: Using cached vision result for image hash {$file_hash}." );
1579 $vision_result = $cached;
1580 } else {
1581 $vision_result = $mwai->simpleVisionQuery( $prompt, $url, $path, [ 'scope' => 'seo' ] );
1582 if ( $vision_result ) {
1583 set_transient( $transient_key, $vision_result, WEEK_IN_SECONDS );
1584 }
1585 }
1586 }
1587
1588 }
1589 }
1590
1591 $field_specs = [
1592 'description' => 'a description of this product (between 120 and 240 words)',
1593 'short_description' => 'a short description (between 20 and 49 words)',
1594 'seo_title' => 'a SEO-friendly title',
1595 'tags' => 'tags (separated by commas)',
1596 ];
1597 $requested_specs = array_map( function( $field ) use ( $field_specs ) {
1598 return $field_specs[ $field ];
1599 }, $fields );
1600
1601 $prompt = "Based on the product, write " . implode( ', ', $requested_specs ) . ". SEO and sales focused. Write it in {LANGUAGE}.";
1602
1603 if ( !empty( $params['product'] ) ) {
1604 $prompt .= "\n\nHere are the user provided details about the product: {PRODUCT}.";
1605 }
1606
1607 // The custom instructions are persistent (Content SEO settings), so single-product
1608 // generation gets them too. The bulk modal sends its own value to override them for
1609 // that run (an explicit empty string means "none").
1610 $instructions = isset( $params['instructions'] )
1611 ? trim( (string) $params['instructions'] )
1612 : trim( (string) $this->get_option( 'woo_assistant_instructions', '' ) );
1613
1614 if ( !empty( $instructions ) ) {
1615 $prompt .= "\n\nHere are general instructions from the user: " . $instructions;
1616 }
1617
1618 $prompt .= "\n\nHere are the details extracted from the product data: " . json_encode( $product_data );
1619
1620 if ( $vision_result ) {
1621 $prompt .= "\n\nAlso, here is what is seen in the product image: " . $vision_result;
1622 }
1623
1624 $prompt = apply_filters( 'mwseo_woo_product_prompt', $prompt );
1625 $prompt .= "Your reply must be a formatted JSON only. Use these keys: " . implode( ', ', $fields ) . ".";
1626
1627 $prompt = str_replace( '{PRODUCT}', $params['product'] ?? '', $prompt );
1628 $prompt = str_replace( '{LANGUAGE}', $params['language'], $prompt );
1629
1630 $response = $mwai->simpleTextQuery( $prompt );
1631
1632 // Remove any "```json" or "```" from the response
1633 $response = preg_replace( '/^```json\s*/', '', $response );
1634 $response = preg_replace( '/\s*```$/', '', $response );
1635
1636 // Ensure the response is valid JSON
1637 $json = json_decode( $response, true );
1638 if ( json_last_error() !== JSON_ERROR_NONE ) {
1639 $this->log( '⚠️ WooCommerce Assistant: Invalid JSON response from AI: ' . json_last_error_msg() );
1640 $this->log( 'Response: ' .$response );
1641 return false;
1642 }
1643
1644 // Only return the requested fields.
1645 return array_intersect_key( $json, array_flip( $fields ) );
1646 }
1647
1648 function apply_woocommerce_fields( $params )
1649 {
1650 $product_id = intval( $params['post_id'] ?? 0 );
1651 $product = get_post( $product_id );
1652 if ( !$product || $product->post_type !== 'product' ) {
1653 throw new Exception( 'Product not found.' );
1654 }
1655
1656 $update = [ 'ID' => $product_id ];
1657 if ( isset( $params['seo_title'] ) && trim( $params['seo_title'] ) !== '' ) {
1658 $update['post_title'] = sanitize_text_field( $params['seo_title'] );
1659 }
1660 if ( isset( $params['description'] ) && trim( $params['description'] ) !== '' ) {
1661 $update['post_content'] = wp_kses_post( $params['description'] );
1662 }
1663 if ( isset( $params['short_description'] ) && trim( $params['short_description'] ) !== '' ) {
1664 $update['post_excerpt'] = wp_kses_post( $params['short_description'] );
1665 }
1666
1667 if ( count( $update ) > 1 ) {
1668 $result = wp_update_post( $update, true );
1669 if ( is_wp_error( $result ) ) {
1670 throw new Exception( $result->get_error_message() );
1671 }
1672 }
1673
1674 if ( !empty( $params['tags'] ) ) {
1675 $tags = is_array( $params['tags'] ) ? $params['tags'] : explode( ',', $params['tags'] );
1676 $tags = array_filter( array_map( function( $tag ) {
1677 return sanitize_text_field( trim( $tag ) );
1678 }, $tags ) );
1679 if ( !empty( $tags ) ) {
1680 // Append: existing tags are kept, already-present tags are not duplicated.
1681 $result = wp_set_object_terms( $product_id, $tags, 'product_tag', true );
1682 if ( is_wp_error( $result ) ) {
1683 throw new Exception( $result->get_error_message() );
1684 }
1685 }
1686 }
1687
1688 return true;
1689 }
1690
1691 #endregion
1692
1693 #region Utilities
1694
1695 function import_yoast() {
1696 // For all the selected post types, get the _yoast_wpseo_title & _yoast_wpseo_metadesc and import them to _kiss_seo_title & _kiss_seo_excerpt
1697 // TODO: Migrate to _mwseo_title & _mwseo_excerpt
1698 $post_types = $this->get_option( 'select_post_types', array( 'post ') );
1699 $posts = get_posts( array(
1700 'post_type' => $post_types,
1701 'posts_per_page' => -1,
1702 'meta_query' => array(
1703 array(
1704 'key' => '_yoast_wpseo_title',
1705 'compare' => 'EXISTS',
1706 ),
1707 array(
1708 'key' => '_yoast_wpseo_metadesc',
1709 'compare' => 'EXISTS',
1710 ),
1711 ),
1712 ) );
1713
1714 foreach ( $posts as $post ) {
1715 $yoast_title = get_post_meta( $post->ID, '_yoast_wpseo_title', true );
1716 $yoast_excerpt = get_post_meta( $post->ID, '_yoast_wpseo_metadesc', true );
1717 $yoast_keywords = get_post_meta( $post->ID, '_yoast_wpseo_focuskw', true );
1718
1719 if ( !empty( $yoast_title ) ) {
1720 update_post_meta( $post->ID, $this->meta_key_seo_title, $yoast_title );
1721 }
1722 if ( !empty( $yoast_excerpt ) ) {
1723 update_post_meta( $post->ID, $this->meta_key_seo_excerpt, $yoast_excerpt );
1724 }
1725 if ( !empty( $yoast_keywords ) ) {
1726 $keywords = explode( ',', $yoast_keywords );
1727 $keywords = array_slice( $keywords, 0, 5 );
1728
1729 update_post_meta( $post->ID, $this->meta_key_seo_keywords, $keywords );
1730 }
1731 }
1732
1733 return array(
1734 'posts' => count( $posts ),
1735 'redirects' => 0,
1736 );
1737 }
1738
1739 function get_seo_title( $post ) {
1740 $seo_title = get_post_meta( $post->ID, $this->meta_key_seo_title, true );
1741 if ( empty( $seo_title ) ) {
1742 $seo_title = $this->build_title( $post );
1743 }
1744 return $seo_title;
1745 }
1746
1747 function set_seo_title( $post, $title ) {
1748 if ( empty( $title ) ) {
1749 return false;
1750 }
1751 $title = trim( strip_tags( $title ) );
1752 if ( empty( $title ) ) {
1753 return false;
1754 }
1755 update_post_meta( $post->ID, $this->meta_key_seo_title, $title );
1756 return true;
1757 }
1758
1759 function get_seo_excerpt( $post ) {
1760 $seo_excerpt = get_post_meta( $post->ID, $this->meta_key_seo_excerpt, true );
1761 if ( empty( $seo_excerpt ) ) {
1762 $seo_excerpt = $this->build_excerpt( $post );
1763 }
1764 return $seo_excerpt;
1765 }
1766
1767 function set_seo_excerpt( $post, $excerpt ) {
1768 if ( empty( $excerpt ) ) {
1769 return false;
1770 }
1771 $excerpt = trim( strip_tags( $excerpt ) );
1772 if ( empty( $excerpt ) ) {
1773 return false;
1774 }
1775 update_post_meta( $post->ID, $this->meta_key_seo_excerpt, $excerpt );
1776 return true;
1777 }
1778
1779 /**
1780 * Calculate effective display width of text for SERP.
1781 * CJK characters (Chinese, Japanese, Korean) are roughly twice as wide as Latin characters
1782 * in Google's search results, so they count as 2 units. This approximates the pixel-based
1783 * measurement that Google actually uses for title/description truncation.
1784 *
1785 * @param string $text The text to measure
1786 * @return int Display width in units (Latin chars = 1, CJK chars = 2)
1787 */
1788 function get_display_width( $text ) {
1789 if ( empty( $text ) ) {
1790 return 0;
1791 }
1792 $width = 0;
1793 // Split into individual characters (UTF-8 safe)
1794 $chars = preg_split( '//u', $text, -1, PREG_SPLIT_NO_EMPTY );
1795 foreach ( $chars as $char ) {
1796 // CJK Unified Ideographs, Extension A, Hiragana, Katakana, Hangul Syllables
1797 if ( preg_match( '/[\x{4E00}-\x{9FFF}\x{3400}-\x{4DBF}\x{3040}-\x{309F}\x{30A0}-\x{30FF}\x{AC00}-\x{D7AF}]/u', $char ) ) {
1798 $width += 2;
1799 } else {
1800 $width += 1;
1801 }
1802 }
1803 return $width;
1804 }
1805
1806 /**
1807 * Check if text is predominantly CJK characters.
1808 *
1809 * @param string $text The text to check
1810 * @param float $threshold Ratio threshold (default 0.5 = 50% CJK)
1811 * @return bool True if text meets or exceeds CJK threshold
1812 */
1813 function is_mostly_cjk( $text, $threshold = 0.5 ) {
1814 if ( empty( $text ) ) {
1815 return false;
1816 }
1817 $text_length = mb_strlen( $text, 'UTF-8' );
1818 if ( $text_length === 0 ) {
1819 return false;
1820 }
1821 // Count CJK characters
1822 preg_match_all( '/[\x{4E00}-\x{9FFF}\x{3400}-\x{4DBF}\x{3040}-\x{309F}\x{30A0}-\x{30FF}\x{AC00}-\x{D7AF}]/u', $text, $matches );
1823 $cjk_count = count( $matches[0] );
1824 return ( $cjk_count / $text_length ) >= $threshold;
1825 }
1826
1827 function import_rank_math() {
1828 // For all the selected post types, get the _rank_math_title & _rank_math_description and import them to _kiss_seo_title & _kiss_seo_excerpt
1829 // TODO: Migrate to _mwseo_title & _mwseo_excerpt
1830 $post_types = $this->get_option( 'select_post_types', array( 'post ') );
1831 $posts = get_posts( array(
1832 'post_type' => $post_types,
1833 'posts_per_page' => -1,
1834 'meta_query' => array(
1835 array(
1836 'key' => 'rank_math_title',
1837 'compare' => 'EXISTS',
1838 ),
1839 array(
1840 'key' => 'rank_math_description',
1841 'compare' => 'EXISTS',
1842 ),
1843 ),
1844 ) );
1845
1846 foreach ( $posts as $post ) {
1847
1848 $rank_math_title = get_post_meta( $post->ID, 'rank_math_title', true );
1849 $rank_math_excerpt = get_post_meta( $post->ID, 'rank_math_description', true );
1850 $rank_math_keywords = get_post_meta( $post->ID, 'rank_math_focus_keyword', true );
1851
1852 if ( !empty( $rank_math_title ) ) {
1853 update_post_meta( $post->ID, $this->meta_key_seo_title, $rank_math_title );
1854 }
1855 if ( !empty( $rank_math_excerpt ) ) {
1856 update_post_meta( $post->ID, $this->meta_key_seo_excerpt, $rank_math_excerpt );
1857 }
1858 if ( !empty( $rank_math_keywords ) ) {
1859 $keywords = explode( ',', $rank_math_keywords );
1860 $keywords = array_slice( $keywords, 0, 5 );
1861
1862 update_post_meta( $post->ID, $this->meta_key_seo_keywords, $keywords );
1863 }
1864 }
1865
1866 // Also import Rank Math redirections that aren't already declared in SEO Engine.
1867 $redirects = 0;
1868 if ( $this->redirects_module ) {
1869 $redirects = (int) $this->redirects_module->import_from_rank_math();
1870 }
1871
1872 return array(
1873 'posts' => count( $posts ),
1874 'redirects' => $redirects,
1875 );
1876 }
1877
1878 function get_language_name( $locale ) {
1879 $locale_name_array = [
1880 'en_US' => 'English (US)',
1881 'en_GB' => 'English (UK)',
1882 'en_AU' => 'English (Australia)',
1883 'en_CA' => 'English (Canada)',
1884 'fr_FR' => 'French',
1885 'fr_BE' => 'French (Belgium)',
1886 'fr_CA' => 'French (Canada)',
1887 'de_DE' => 'German',
1888 'de_CH' => 'German (Switzerland)',
1889 'de_AT' => 'German (Austria)',
1890 'it_IT' => 'Italian',
1891 'pt_PT' => 'Portuguese',
1892 'pt_BR' => 'Portuguese (Brazil)',
1893 'es_ES' => 'Spanish',
1894 'es_MX' => 'Spanish (Mexico)',
1895 'es_GT' => 'Spanish (Guatemala)',
1896 'es_CL' => 'Spanish (Chile)',
1897 'es_AR' => 'Spanish (Argentina)',
1898 'es_CO' => 'Spanish (Colombia)',
1899 'es_PE' => 'Spanish (Peru)',
1900 'es_VE' => 'Spanish (Venezuela)',
1901 'nl_NL' => 'Dutch',
1902 'nl_BE' => 'Dutch (Belgium)',
1903 'ru_RU' => 'Russian',
1904 'pl_PL' => 'Polish',
1905 'ja' => 'Japanese',
1906 'zh_CN' => 'Chinese (China)',
1907 'zh_TW' => 'Chinese (Taiwan)',
1908 'ko_KR' => 'Korean',
1909 'ar' => 'Arabic',
1910 'he_IL' => 'Hebrew',
1911 'id_ID' => 'Indonesian',
1912 'ms_MY' => 'Malay',
1913 'th' => 'Thai',
1914 'vi' => 'Vietnamese',
1915 'tr_TR' => 'Turkish',
1916 'bg_BG' => 'Bulgarian',
1917 'el' => 'Greek',
1918 'da_DK' => 'Danish',
1919 'fa_IR' => 'Persian',
1920 'fi' => 'Finnish',
1921 'hi_IN' => 'Hindi',
1922 'hr' => 'Croatian',
1923 'hu_HU' => 'Hungarian',
1924 'nb_NO' => 'Norwegian (Bokmål)',
1925 'ro_RO' => 'Romanian',
1926 'sl_SI' => 'Slovenian',
1927 'sv_SE' => 'Swedish',
1928 'uk' => 'Ukrainian',
1929 'cs_CZ' => 'Czech',
1930 'sk_SK' => 'Slovak',
1931 'lt_LT' => 'Lithuanian',
1932 'mk_MK' => 'Macedonian',
1933 'mg_MG' => 'Malagasy',
1934 'ta_IN' => 'Tamil',
1935 'tl' => 'Tagalog',
1936 'az_AZ' => 'Azerbaijani',
1937 'az_TR' => 'Azerbaijani (Turkey)'
1938 ];
1939
1940 if ( isset( $locale_name_array[ $locale ] ) ) {
1941 return $locale_name_array[ $locale ];
1942 }
1943 else {
1944 return null;
1945 }
1946 }
1947
1948 // Returns a human-readable language name for AI prompts.
1949 // Falls back to the global 'language' option when no multilingual plugin is
1950 // active or the post has no language assigned.
1951 function get_post_language_name( $post_id ) {
1952 $post_locale = null;
1953 if ( function_exists( 'pll_get_post_language' ) ) {
1954 $post_locale = pll_get_post_language( $post_id, 'locale' );
1955 }
1956 elseif ( function_exists( 'wpml_get_language_information' ) ) {
1957 $info = wpml_get_language_information( null, $post_id );
1958 if ( ! is_wp_error( $info ) && ! empty( $info['locale'] ) ) {
1959 $post_locale = $info['locale'];
1960 }
1961 }
1962 elseif ( function_exists( 'bogo_get_post_locale' ) ) {
1963 // Bogo stores the locale in the _locale meta and falls back to the
1964 // site default itself, so this is always a usable locale.
1965 $post_locale = bogo_get_post_locale( $post_id );
1966 }
1967 $name = null;
1968 if ( $post_locale ) {
1969 $name = $this->get_language_name( $post_locale );
1970 }
1971 if ( !$name ) {
1972 $name = $this->get_option( 'language', 'English' );
1973 }
1974 // Escape hatch for setups we don't detect (custom or unsupported
1975 // multilingual plugins): return the language AI should write in.
1976 return apply_filters( 'mwseo_post_language_name', $name, $post_id, $post_locale );
1977 }
1978
1979 /**
1980 * The post's language as an identifier used to group/filter posts, or null
1981 * when no multilingual plugin is active. Polylang and WPML return a short
1982 * slug ("fr"); Bogo returns the full locale ("fr_FR") because two Bogo
1983 * locales can share a slug and we need them to stay distinct.
1984 */
1985 function get_post_language_slug( $post_id ) {
1986 if ( function_exists( 'pll_get_post_language' ) ) {
1987 $slug = pll_get_post_language( $post_id, 'slug' );
1988 return $slug ? $slug : null;
1989 }
1990 if ( function_exists( 'wpml_get_language_information' ) ) {
1991 $info = wpml_get_language_information( null, $post_id );
1992 return ( !is_wp_error( $info ) && !empty( $info['language_code'] ) ) ? $info['language_code'] : null;
1993 }
1994 if ( function_exists( 'bogo_get_post_locale' ) ) {
1995 $locale = bogo_get_post_locale( $post_id );
1996 return $locale ? $locale : null;
1997 }
1998 return null;
1999 }
2000
2001 /**
2002 * IDs of the post's translations. May include the post itself (callers merge
2003 * it into an exclusion list anyway).
2004 */
2005 function get_post_translation_ids( $post_id ) {
2006 if ( function_exists( 'pll_get_post_translations' ) ) {
2007 $ids = pll_get_post_translations( $post_id );
2008 return is_array( $ids ) ? array_values( array_map( 'intval', $ids ) ) : array();
2009 }
2010 if ( function_exists( 'wpml_get_language_information' ) ) {
2011 global $sitepress;
2012 $post = get_post( $post_id );
2013 if ( $sitepress && $post ) {
2014 $trid = $sitepress->get_element_trid( $post_id, 'post_' . $post->post_type );
2015 $translations = $sitepress->get_element_translations( $trid, 'post_' . $post->post_type );
2016 if ( is_array( $translations ) ) {
2017 return array_values( array_map( 'intval', wp_list_pluck( $translations, 'element_id' ) ) );
2018 }
2019 }
2020 return array();
2021 }
2022 if ( function_exists( 'bogo_get_post_translations' ) ) {
2023 // Bogo returns WP_Post objects keyed by locale, and excludes the post itself.
2024 $posts = bogo_get_post_translations( $post_id );
2025 if ( is_array( $posts ) ) {
2026 return array_values( array_map( function ( $p ) {
2027 return isset( $p->ID ) ? (int) $p->ID : (int) $p;
2028 }, $posts ) );
2029 }
2030 return array();
2031 }
2032 return array();
2033 }
2034
2035 /**
2036 * Restrict WP_Query args to one language. $language is an identifier from
2037 * get_available_languages(); 'all' (or empty) means every language.
2038 *
2039 * Every query we run outside the admin has to go through this, even when no
2040 * language is selected: Bogo hooks parse_query and narrows ANY query it sees
2041 * down to get_locale() unless bogo_suppress_locale_query is set. REST requests
2042 * are not is_admin(), so without that opt-out our lists, scans and sitemaps
2043 * silently returned the site language only, and asking for another language
2044 * could never match (Bogo's own WHERE was already ANDed in).
2045 */
2046 function apply_language_filter( $args, $language ) {
2047 $is_bogo = function_exists( 'bogo_get_post_locale' );
2048 if ( $is_bogo ) {
2049 $args['bogo_suppress_locale_query'] = true;
2050 }
2051 if ( empty( $language ) || $language === 'all' ) {
2052 return $args;
2053 }
2054 if ( function_exists( 'pll_get_post_language' ) ) {
2055 $args['tax_query'][] = array(
2056 'taxonomy' => 'language',
2057 'field' => 'slug',
2058 'terms' => $language,
2059 );
2060 return $args;
2061 }
2062 if ( $is_bogo ) {
2063 // Filter on Bogo's _locale meta rather than its own 'lang' query var:
2064 // get_posts() defaults to suppress_filters, so the WHERE clause Bogo
2065 // builds from 'lang' would never run for half of our queries.
2066 $clause = array(
2067 'relation' => 'OR',
2068 array( 'key' => '_locale', 'value' => $language ),
2069 );
2070 // Posts in the site's default language often have no _locale row at
2071 // all (Bogo only writes it for translations), so treat "missing" as
2072 // the default locale.
2073 $default = function_exists( 'bogo_get_default_locale' ) ? bogo_get_default_locale() : null;
2074 if ( $default && $language === $default ) {
2075 $clause[] = array( 'key' => '_locale', 'compare' => 'NOT EXISTS' );
2076 }
2077 $args['meta_query'][] = $clause;
2078 return $args;
2079 }
2080 return $args;
2081 }
2082
2083 /**
2084 * Languages available on the site, as [ [ 'slug' => ..., 'name' => ... ] ].
2085 * Empty when no supported multilingual plugin is active.
2086 */
2087 function get_available_languages() {
2088 $languages = array();
2089 if ( function_exists( 'pll_languages_list' ) ) {
2090 $slugs = pll_languages_list();
2091 $names = pll_languages_list( array( 'fields' => 'name' ) );
2092 foreach ( (array) $slugs as $i => $slug ) {
2093 $languages[] = array( 'slug' => $slug, 'name' => $names[ $i ] ?? $slug );
2094 }
2095 return $languages;
2096 }
2097 if ( function_exists( 'bogo_available_locales' ) ) {
2098 foreach ( (array) bogo_available_locales() as $locale ) {
2099 $name = function_exists( 'bogo_get_language_native_name' )
2100 ? bogo_get_language_native_name( $locale ) : '';
2101 $languages[] = array( 'slug' => $locale, 'name' => $name ? $name : $locale );
2102 }
2103 return $languages;
2104 }
2105 return $languages;
2106 }
2107
2108 #endregion
2109
2110 /**
2111 * Redirect attachment URLs to their parent post URLs
2112 */
2113 public function redirect_attachment_to_parent( $link, $post_id ) {
2114 $attachment = get_post( $post_id );
2115 if ( $attachment && $attachment->post_type === 'attachment' && $attachment->post_parent ) {
2116 return get_permalink( $attachment->post_parent );
2117 }
2118 return $link;
2119 }
2120
2121 /**
2122 * Handle attachment page redirects to parent post
2123 */
2124 public function handle_attachment_redirect()
2125 {
2126 if ( is_attachment() ) {
2127 global $post;
2128
2129 if ( $post && $post->post_parent ) {
2130 wp_safe_redirect( esc_url( get_permalink( $post->post_parent ) ), 301 ) ;
2131 } else {
2132 wp_safe_redirect ( esc_url( home_url( '/' ) ), 301 );
2133 }
2134 }
2135 }
2136
2137 #region Sitemap
2138
2139 function generate_sitemap() {
2140 if ( !isset( $this->sitemap_module ) ) {
2141 return false;
2142 }
2143
2144 return $this->sitemap_module->create_sitemap();
2145 }
2146
2147
2148 #endregion
2149
2150 #region Robots.txt
2151
2152 function get_robots_txt() {
2153 if ( ! function_exists( 'get_home_path' ) ) {
2154 require_once ABSPATH . 'wp-admin/includes/file.php';
2155 }
2156 $home_path = get_home_path();
2157
2158 if ( ! is_writable( $home_path ) && ! empty( $_SERVER['DOCUMENT_ROOT'] ) ) {
2159 $home_path = $_SERVER['DOCUMENT_ROOT'] . DIRECTORY_SEPARATOR;
2160 }
2161
2162 $robots_path = $home_path . 'robots.txt';
2163 $source = 'none';
2164
2165
2166 if ( file_exists( $robots_path ) ) {
2167 $robotsTxt = file_get_contents( $robots_path );
2168 $source = 'file';
2169 } else {
2170 // Get the default WordPress robots.txt content
2171 ob_start();
2172 do_robots();
2173 $robotsTxt = ob_get_clean();
2174 $source = 'default';
2175 }
2176
2177 return [
2178 'content' => $robotsTxt,
2179 'source' => $source,
2180 'path' => $robots_path,
2181 ];
2182 }
2183
2184 function set_robots_txt( $content ) {
2185 if ( ! function_exists( 'get_home_path' ) ) {
2186 require_once ABSPATH . 'wp-admin/includes/file.php';
2187 }
2188 $home_path = get_home_path();
2189
2190 if ( ! is_writable( $home_path ) && ! empty( $_SERVER['DOCUMENT_ROOT'] ) ) {
2191 $home_path = $_SERVER['DOCUMENT_ROOT'] . DIRECTORY_SEPARATOR;
2192 }
2193
2194 $robots_file = $home_path . 'robots.txt';
2195
2196 if( !is_writable( $home_path ) ) {
2197 return false;
2198 }
2199
2200 if( !file_exists( $robots_file ) ) {
2201 ob_start();
2202 error_reporting( 0 );
2203 do_robots();
2204 $robots_content = ob_get_clean();
2205
2206 $f = fopen( $robots_file, 'x' );
2207 fwrite( $f, $robots_content );
2208 fclose( $f );
2209 }
2210
2211 if ( !empty( $content ) ) {
2212 $content = sanitize_textarea_field( wp_unslash( $content ) );
2213 $f = fopen( $robots_file, 'w+' );
2214 fwrite( $f, $content );
2215 fclose( $f );
2216 }
2217
2218 return true;
2219 }
2220
2221 #endregion
2222
2223 #region LLMs.txt
2224
2225 function get_llms_txt() {
2226 if ( ! function_exists( 'get_home_path' ) ) {
2227 require_once ABSPATH . 'wp-admin/includes/file.php';
2228 }
2229 $home_path = get_home_path();
2230
2231 if ( ! is_writable( $home_path ) && ! empty( $_SERVER['DOCUMENT_ROOT'] ) ) {
2232 $home_path = $_SERVER['DOCUMENT_ROOT'] . DIRECTORY_SEPARATOR;
2233 }
2234
2235 $llms_path = $home_path . 'llms.txt';
2236 $source = 'none';
2237
2238 if ( file_exists( $llms_path ) ) {
2239 $llmsTxt = file_get_contents( $llms_path );
2240 $source = 'file';
2241 } else {
2242 $llmsTxt = $this->get_template_llms();
2243 $source = 'template';
2244 }
2245
2246 return [
2247 'content' => $llmsTxt,
2248 'source' => $source,
2249 'path' => $llms_path,
2250 ];
2251 }
2252
2253 function get_template_llms() {
2254
2255 $site_url = get_site_url();
2256 $site_name = get_bloginfo( 'name' );
2257 $site_desc = get_bloginfo( 'description' );
2258
2259 $summary = !empty( $site_desc )
2260 ? $site_desc
2261 : 'Short summary describing what this site is about.';
2262
2263 $lines = [];
2264 $lines[] = '# ' . $site_name;
2265 $lines[] = '';
2266 $lines[] = '> ' . $summary;
2267 $lines[] = '';
2268
2269 // Pages section: front/blog page + top-level pages.
2270 $page_entries = [];
2271 $front_id = (int) get_option( 'page_on_front' );
2272 $blog_id = (int) get_option( 'page_for_posts' );
2273 $seen = [];
2274 $push_page = function( $p ) use ( &$page_entries, &$seen ) {
2275 if ( !$p || $p->post_status !== 'publish' ) return;
2276 $url = get_permalink( $p );
2277 if ( isset( $seen[ $url ] ) ) return;
2278 $seen[ $url ] = true;
2279 $title = !empty( $p->post_title ) ? $p->post_title : $url;
2280 $page_entries[] = '* [' . $title . '](' . $url . '): Add a one-line description for this page.';
2281 };
2282 if ( $front_id ) $push_page( get_post( $front_id ) );
2283 if ( $blog_id && $blog_id !== $front_id ) $push_page( get_post( $blog_id ) );
2284 $top_pages = get_posts( [
2285 'post_type' => 'page',
2286 'post_status' => 'publish',
2287 'posts_per_page' => 5,
2288 'orderby' => 'menu_order title',
2289 'order' => 'ASC',
2290 'post_parent' => 0,
2291 ] );
2292 foreach ( $top_pages as $p ) $push_page( $p );
2293
2294 if ( !empty( $page_entries ) ) {
2295 $lines[] = '## Pages';
2296 foreach ( $page_entries as $e ) $lines[] = $e;
2297 $lines[] = '';
2298 }
2299
2300 // Articles section: most recent published posts.
2301 $recent = wp_get_recent_posts( [
2302 'numberposts' => 5,
2303 'post_status' => 'publish',
2304 ], OBJECT );
2305 if ( !empty( $recent ) ) {
2306 $lines[] = '## Articles';
2307 foreach ( $recent as $p ) {
2308 $url = get_permalink( $p );
2309 if ( isset( $seen[ $url ] ) ) continue;
2310 $seen[ $url ] = true;
2311 $title = !empty( $p->post_title ) ? $p->post_title : $url;
2312 $lines[] = '* [' . $title . '](' . $url . '): Add a one-line description for this article.';
2313 }
2314 $lines[] = '';
2315 }
2316
2317 // Always include an Optional section so users see the spec pattern.
2318 if ( empty( $page_entries ) && empty( $recent ) ) {
2319 $lines[] = '## Pages';
2320 $lines[] = '* [Page Title](' . $site_url . '/page-url/): Add a one-line description for this page.';
2321 $lines[] = '';
2322 $lines[] = '## Articles';
2323 $lines[] = '* [Article Title](' . $site_url . '/post-url/): Add a one-line description for this article.';
2324 $lines[] = '';
2325 }
2326
2327 $lines[] = '## Optional';
2328 $lines[] = '* [Lower-priority resource](' . $site_url . '/optional-url/): Content that can be skipped when context is limited.';
2329 $lines[] = '';
2330
2331 return implode( "\n", $lines );
2332
2333 }
2334
2335 function set_llms_txt( $content ) {
2336 if ( ! function_exists( 'get_home_path' ) ) {
2337 require_once ABSPATH . 'wp-admin/includes/file.php';
2338 }
2339 $home_path = get_home_path();
2340
2341 if ( ! is_writable( $home_path ) && ! empty( $_SERVER['DOCUMENT_ROOT'] ) ) {
2342 $home_path = $_SERVER['DOCUMENT_ROOT'] . DIRECTORY_SEPARATOR;
2343 }
2344
2345 $llms_path = $home_path . 'llms.txt';
2346
2347 if( !is_writable( $home_path ) ) {
2348 return false;
2349 }
2350
2351 if ( empty( $content ) ) {
2352 $content = $this->get_template_llms();
2353 }
2354
2355 $site_url = get_site_url();
2356 $site_name = get_bloginfo( 'name' );
2357
2358 $content = str_replace( '{SITE_URL}', $site_url, $content );
2359 $content = str_replace( '{SITE_NAME}', $site_name, $content );
2360
2361 // Handle UTF-8 BOM for proper browser encoding detection
2362 $utf8_bom = "\xEF\xBB\xBF";
2363 if ( substr( $content, 0, 3 ) === $utf8_bom ) {
2364 $content = substr( $content, 3 );
2365 }
2366
2367 $content = $utf8_bom . $content;
2368
2369 if ( !empty( $content ) ) {
2370 $content = sanitize_textarea_field( wp_unslash( $content ) );
2371 $f = fopen( $llms_path, 'w+' );
2372 fwrite( $f, $content );
2373 fclose( $f );
2374 }
2375
2376 return true;
2377 }
2378
2379 function delete_llms_txt() {
2380 if ( ! function_exists( 'get_home_path' ) ) {
2381 require_once ABSPATH . 'wp-admin/includes/file.php';
2382 }
2383 $home_path = get_home_path();
2384
2385 if ( ! is_writable( $home_path ) && ! empty( $_SERVER['DOCUMENT_ROOT'] ) ) {
2386 $home_path = $_SERVER['DOCUMENT_ROOT'] . DIRECTORY_SEPARATOR;
2387 }
2388
2389 $llms_path = $home_path . 'llms.txt';
2390
2391 if ( ! file_exists( $llms_path ) ) {
2392 return false;
2393 }
2394
2395 if ( ! is_writable( $llms_path ) ) {
2396 return false;
2397 }
2398
2399 return @unlink( $llms_path );
2400 }
2401
2402 #endregion
2403
2404 #region Performance Insights
2405
2406 function get_speed_and_vitals( $post_id ) {
2407 $result = $this->insights_module->get_insights_for_post( $post_id );
2408
2409 return $result;
2410 }
2411
2412 function get_last_insights() {
2413 $last_insights = $this->insights_module->get_last_insights();
2414 if ( !is_array( $last_insights ) ) {
2415 return [];
2416 }
2417
2418 return $last_insights;
2419 }
2420
2421 #endregion
2422
2423 #region Analytics
2424
2425 function track_current_visit() {
2426 if ( !isset( $this->analytics_module ) ) {
2427 return;
2428 }
2429
2430 // Only track single posts and pages
2431 if ( is_singular() ) {
2432 global $post;
2433 if ( $post && $post->ID ) {
2434 $this->analytics_module->track_visit( $post->ID );
2435 }
2436 }
2437 }
2438
2439 #region AI Visibility delegators
2440
2441 // AI Visibility is a Pro module (premium/aivisibility.php); it simply does not
2442 // exist in the free build, so this returns null there.
2443 function ai_visibility() {
2444 return ( $this->pro && isset( $this->pro->ai_visibility ) ) ? $this->pro->ai_visibility : null;
2445 }
2446
2447 #endregion
2448
2449 // TODO [2025]: Refactor to unified analytics provider interface
2450 function get_analytics_data( $args = array() ) {
2451 if ( !isset( $this->analytics_module ) ) {
2452 return array();
2453 }
2454
2455 return $this->analytics_module->get_analytics_data( $args );
2456 }
2457
2458 // TODO [2025]: Refactor to unified analytics provider interface
2459 function get_analytics_summary( $start_date = null, $end_date = null ) {
2460 if ( !isset( $this->analytics_module ) ) {
2461 return array();
2462 }
2463
2464 return $this->analytics_module->get_analytics_summary( $start_date, $end_date );
2465 }
2466
2467 function get_analytics_realtime_data() {
2468 if ( !isset( $this->analytics_module ) ) {
2469 return array();
2470 }
2471
2472 return $this->analytics_module->get_realtime_data();
2473 }
2474
2475 // TODO [2025]: Refactor to unified analytics provider interface
2476 function get_top_posts( $args = array() ) {
2477 if ( !isset( $this->analytics_module ) ) {
2478 return array();
2479 }
2480
2481 return $this->analytics_module->get_top_posts( $args );
2482 }
2483
2484 function get_ai_agents_summary( $start_date = null, $end_date = null ) {
2485 if ( !isset( $this->analytics_module ) ) {
2486 return array();
2487 }
2488
2489 return $this->analytics_module->get_ai_agents_summary( $start_date, $end_date );
2490 }
2491
2492 function get_ai_agent_details( $bot_name, $start_date = null, $end_date = null ) {
2493 if ( !isset( $this->analytics_module ) ) {
2494 return array();
2495 }
2496
2497 return $this->analytics_module->get_ai_agent_details( $bot_name, $start_date, $end_date );
2498 }
2499
2500 function get_ai_agents_by_post( $post_id, $days = 30 ) {
2501 if ( !isset( $this->analytics_module ) ) {
2502 return array(
2503 'openai' => 0,
2504 'anthropic' => 0,
2505 'google' => 0,
2506 'others' => 0
2507 );
2508 }
2509
2510 return $this->analytics_module->get_ai_agents_by_post( $post_id, $days );
2511 }
2512
2513 function get_ai_bots_totals_by_posts( $post_ids, $days = 30 ) {
2514 if ( !isset( $this->analytics_module ) ) {
2515 return array();
2516 }
2517 return $this->analytics_module->get_ai_bots_totals_by_posts( $post_ids, $days );
2518 }
2519
2520 function query_bot_traffic( $args = array() ) {
2521 if ( !isset( $this->analytics_module ) ) {
2522 return array();
2523 }
2524
2525 return $this->analytics_module->query_bot_traffic( $args );
2526 }
2527
2528 function get_bots_by_type( $type ) {
2529 if ( !isset( $this->analytics_module ) ) {
2530 return array();
2531 }
2532
2533 return $this->analytics_module->get_bots_by_type( $type );
2534 }
2535
2536 function rank_posts_for_bots( $args = array() ) {
2537 if ( !isset( $this->analytics_module ) ) {
2538 return array();
2539 }
2540
2541 return $this->analytics_module->rank_posts_for_bots( $args );
2542 }
2543
2544 function get_bot_profile( $bot_name, $start_date = null, $end_date = null ) {
2545 if ( !isset( $this->analytics_module ) ) {
2546 return array();
2547 }
2548
2549 return $this->analytics_module->get_bot_profile( $bot_name, $start_date, $end_date );
2550 }
2551
2552 function compare_bot_periods( $args = array() ) {
2553 if ( !isset( $this->analytics_module ) ) {
2554 return array();
2555 }
2556
2557 return $this->analytics_module->compare_bot_periods( $args );
2558 }
2559
2560 function get_bot_mix( $args = array() ) {
2561 if ( !isset( $this->analytics_module ) ) {
2562 return array();
2563 }
2564
2565 return $this->analytics_module->get_bot_mix( $args );
2566 }
2567
2568 #endregion
2569
2570 #region Google Analytics
2571 function get_google_analytics_state() {
2572 if ( !class_exists( 'Meow_MWSEO_Modules_GoogleAnalytics' ) ) {
2573 return [];
2574 }
2575
2576 // Get property_id with fallback to first element of property_ids array
2577 $property_id = $this->get_option( 'google_analytics_property_id', '' );
2578 if ( empty( $property_id ) ) {
2579 $property_ids = $this->get_option( 'google_analytics_property_ids', [] );
2580 if ( !empty( $property_ids ) && is_array( $property_ids ) ) {
2581 $property_id = $property_ids[0];
2582 $this->update_option( 'google_analytics_property_id', $property_id );
2583 }
2584 }
2585
2586 return [
2587 'redirect_url' => $this->get_google_redirect_url(),
2588 'property_id' => $property_id,
2589 ];
2590 }
2591
2592
2593 function unlink_google_analytics() {
2594 if ( !isset( $this->googleanalytics_module ) ) {
2595 return false;
2596 }
2597
2598 return $this->googleanalytics_module->unlink();
2599 }
2600
2601 function get_is_authenticated() {
2602 if ( !isset( $this->googleanalytics_module ) ) {
2603 return false;
2604 }
2605
2606 // Refresh the options
2607 $this->googleanalytics_module->init();
2608
2609 return $this->googleanalytics_module->is_authenticated();
2610 }
2611
2612 function get_google_auth_url() {
2613 if ( !isset( $this->googleanalytics_module ) ) {
2614 return '';
2615 }
2616
2617 // Refresh the options
2618 $this->googleanalytics_module->init();
2619
2620 return $this->googleanalytics_module->get_auth_url();
2621 }
2622
2623 function get_google_redirect_url() {
2624 if ( !isset( $this->googleanalytics_module ) ) {
2625 return '';
2626 }
2627
2628 return $this->googleanalytics_module->get_redirect_url();
2629 }
2630
2631 #endregion
2632
2633 #region Google Search Console
2634
2635 function get_google_search_console_state() {
2636 if ( !$this->pro || !$this->pro->search_console ) {
2637 return [];
2638 }
2639 $gsc = $this->pro->search_console;
2640 return [
2641 'redirect_url' => $gsc->get_redirect_url(),
2642 'property' => $gsc->get_property(),
2643 'tracked' => $gsc->get_tracked_properties(),
2644 'is_authenticated' => $gsc->is_authenticated(),
2645 ];
2646 }
2647
2648 function toggle_gsc_tracked_property( $property, $tracked ) {
2649 if ( !$this->pro || !$this->pro->search_console ) {
2650 return false;
2651 }
2652 return $this->pro->search_console->toggle_tracked_property( $property, $tracked );
2653 }
2654
2655 function get_gsc_summary( $args = [] ) {
2656 if ( !$this->pro || !$this->pro->search_console ) return [];
2657 return $this->pro->search_console->get_summary( $args );
2658 }
2659
2660 function get_gsc_timeseries( $args = [] ) {
2661 if ( !$this->pro || !$this->pro->search_console ) return [];
2662 return $this->pro->search_console->get_timeseries( $args );
2663 }
2664
2665 function get_gsc_page_movers( $args = [] ) {
2666 if ( !$this->pro || !$this->pro->search_console ) return [];
2667 return $this->pro->search_console->get_page_movers( $args );
2668 }
2669
2670 function get_gsc_quick_wins( $args = [] ) {
2671 if ( !$this->pro || !$this->pro->search_console ) return [];
2672 return $this->pro->search_console->get_quick_wins( $args );
2673 }
2674
2675 function get_gsc_top_pages( $args = [] ) {
2676 if ( !$this->pro || !$this->pro->search_console ) return [];
2677 return $this->pro->search_console->get_top_pages( $args );
2678 }
2679
2680 function get_gsc_top_queries( $args = [] ) {
2681 if ( !$this->pro || !$this->pro->search_console ) return [];
2682 return $this->pro->search_console->get_top_queries( $args );
2683 }
2684
2685 function get_gsc_post_metrics_map( $args = [] ) {
2686 if ( !$this->pro || !$this->pro->search_console ) return [];
2687 return $this->pro->search_console->get_post_metrics_map( $args );
2688 }
2689
2690 function estimate_gsc_quick_win_potential( $impressions, $position ) {
2691 if ( !$this->pro || !$this->pro->search_console ) return 0;
2692 return $this->pro->search_console->estimate_quick_win_potential( $impressions, $position );
2693 }
2694
2695 function get_gsc_search_breakdown( $args = [] ) {
2696 if ( !$this->pro || !$this->pro->search_console ) return [ 'top_queries' => [], 'top_pages' => [] ];
2697 return $this->pro->search_console->get_search_breakdown( $args );
2698 }
2699
2700 function get_gsc_pages_with_issues( $args = [] ) {
2701 if ( !$this->pro || !$this->pro->search_console ) return [];
2702 return $this->pro->search_console->get_pages_with_issues( $args );
2703 }
2704
2705 function get_gsc_post_pulse( $post_id, $days = 28 ) {
2706 if ( !$this->pro || !$this->pro->search_console ) return null;
2707 return $this->pro->search_console->get_post_pulse( (int) $post_id, $days );
2708 }
2709
2710 function get_gsc_auth_url() {
2711 if ( !$this->pro || !$this->pro->search_console ) {
2712 return '';
2713 }
2714 $this->pro->search_console->init();
2715 return $this->pro->search_console->get_auth_url();
2716 }
2717
2718 function is_gsc_authenticated() {
2719 if ( !$this->pro || !$this->pro->search_console ) {
2720 return false;
2721 }
2722 $this->pro->search_console->init();
2723 return $this->pro->search_console->is_authenticated();
2724 }
2725
2726 function unlink_gsc() {
2727 if ( !$this->pro || !$this->pro->search_console ) {
2728 return false;
2729 }
2730 return $this->pro->search_console->unlink();
2731 }
2732
2733 function list_gsc_properties() {
2734 if ( !$this->pro || !$this->pro->search_console ) {
2735 return [];
2736 }
2737 $properties = $this->pro->search_console->list_properties();
2738 return is_array( $properties ) ? $properties : [];
2739 }
2740
2741 function set_gsc_property( $property ) {
2742 if ( !$this->pro || !$this->pro->search_console ) {
2743 return false;
2744 }
2745 return $this->pro->search_console->set_property( $property );
2746 }
2747
2748 #endregion
2749
2750 // TODO [2025]: Refactor to unified analytics provider interface
2751 function get_google_analytics_data( $args = array() ) {
2752 if ( !isset( $this->googleanalytics_module ) ) {
2753 return array();
2754 }
2755
2756 return $this->googleanalytics_module->get_analytics_data( $args );
2757 }
2758
2759 function get_post_analytics( $post_id, $page_path, $start_date = null, $end_date = null ) {
2760 if ( !isset( $this->analytics_module ) ) {
2761 return array();
2762 }
2763
2764 return $this->analytics_module->get_post_analytics( $post_id, $page_path, $start_date, $end_date );
2765 }
2766
2767 function get_posts_visitor_series( $post_ids, $days = 30 ) {
2768 if ( !isset( $this->analytics_module ) ) {
2769 return array();
2770 }
2771 return $this->analytics_module->get_posts_visitor_series( $post_ids, $days );
2772 }
2773
2774 function get_posts_visitor_totals( $post_ids, $days = 30 ) {
2775 if ( !isset( $this->analytics_module ) ) {
2776 return array();
2777 }
2778 return $this->analytics_module->get_posts_visitor_totals( $post_ids, $days );
2779 }
2780
2781 function get_google_analytics_pages_daily( $start_date = null, $end_date = null, $paths = null ) {
2782 if ( !isset( $this->googleanalytics_module ) ) {
2783 return array();
2784 }
2785 $this->googleanalytics_module->init();
2786 return $this->googleanalytics_module->get_pages_daily_visitors( $start_date, $end_date, $paths );
2787 }
2788
2789 function get_google_analytics_pages_totals( $start_date = null, $end_date = null ) {
2790 if ( !isset( $this->googleanalytics_module ) ) {
2791 return array();
2792 }
2793 $this->googleanalytics_module->init();
2794 return $this->googleanalytics_module->get_pages_total_visitors( $start_date, $end_date );
2795 }
2796
2797 // TODO [2025]: Refactor to unified analytics provider interface
2798 function get_google_analytics_summary( $start_date = null, $end_date = null ) {
2799 if ( !isset( $this->googleanalytics_module ) ) {
2800 return array();
2801 }
2802
2803 return $this->googleanalytics_module->get_analytics_summary( $start_date, $end_date );
2804 }
2805
2806 function get_google_analytics_post_analytics( $page_path, $start_date = null, $end_date = null ) {
2807 if ( !isset( $this->googleanalytics_module ) ) {
2808 return array();
2809 }
2810
2811 return $this->googleanalytics_module->get_post_analytics( $page_path, $start_date, $end_date );
2812 }
2813
2814 // TODO [2025]: Refactor to unified analytics provider interface
2815 function get_google_analytics_top_posts( $args = array() ) {
2816 if ( !isset( $this->googleanalytics_module ) ) {
2817 return array();
2818 }
2819
2820 return $this->googleanalytics_module->get_top_posts( $args );
2821 }
2822
2823 // TODO [2025]: Refactor to unified analytics provider interface
2824 function get_google_analytics_realtime_data() {
2825 if ( !isset( $this->googleanalytics_module ) ) {
2826 return array();
2827 }
2828
2829 return $this->googleanalytics_module->get_realtime_data();
2830 }
2831
2832 // Plausible Analytics
2833
2834 // TODO [2025]: Refactor to unified analytics provider interface
2835 function get_plausible_analytics_data( $args = array() ) {
2836 if ( !isset( $this->plausibleanalytics_module ) ) {
2837 return array();
2838 }
2839
2840 return $this->plausibleanalytics_module->get_data( $args );
2841 }
2842
2843 // TODO [2025]: Refactor to unified analytics provider interface
2844 function get_plausible_analytics_summary( $start_date = null, $end_date = null ) {
2845 if ( !isset( $this->plausibleanalytics_module ) ) {
2846 return array();
2847 }
2848
2849 return $this->plausibleanalytics_module->get_summary( $start_date, $end_date );
2850 }
2851
2852 function get_plausible_analytics_post_analytics( $page_path, $start_date = null, $end_date = null ) {
2853 if ( !isset( $this->plausibleanalytics_module ) ) {
2854 return array();
2855 }
2856
2857 return $this->plausibleanalytics_module->get_post_analytics( $page_path, $start_date, $end_date );
2858 }
2859
2860 function get_plausible_analytics_realtime_data() {
2861 if ( !isset( $this->plausibleanalytics_module ) ) {
2862 return array();
2863 }
2864
2865 return $this->plausibleanalytics_module->get_realtime_data();
2866 }
2867
2868 // TODO [2025]: Refactor to unified analytics provider interface
2869 function get_plausible_analytics_top_posts( $start_date = null, $end_date = null, $limit = 10 ) {
2870 if ( !isset( $this->plausibleanalytics_module ) ) {
2871 return array();
2872 }
2873
2874 return $this->plausibleanalytics_module->get_top_posts( $start_date, $end_date, $limit );
2875 }
2876
2877 // Matomo Analytics
2878
2879 function get_matomo_analytics_data( $args = array() ) {
2880 if ( !isset( $this->matomoanalytics_module ) ) {
2881 return array();
2882 }
2883
2884 return $this->matomoanalytics_module->get_data( $args );
2885 }
2886
2887 function get_matomo_analytics_summary( $start_date = null, $end_date = null ) {
2888 if ( !isset( $this->matomoanalytics_module ) ) {
2889 return array();
2890 }
2891
2892 return $this->matomoanalytics_module->get_summary( $start_date, $end_date );
2893 }
2894
2895 function get_matomo_analytics_post_analytics( $page_path, $start_date = null, $end_date = null ) {
2896 if ( !isset( $this->matomoanalytics_module ) ) {
2897 return array();
2898 }
2899
2900 return $this->matomoanalytics_module->get_post_analytics( $page_path, $start_date, $end_date );
2901 }
2902
2903 function get_matomo_analytics_top_posts( $args = array() ) {
2904 if ( !isset( $this->matomoanalytics_module ) ) {
2905 return array();
2906 }
2907
2908 return $this->matomoanalytics_module->get_top_posts( $args );
2909 }
2910
2911 function get_matomo_analytics_realtime_data() {
2912 if ( !isset( $this->matomoanalytics_module ) ) {
2913 return array();
2914 }
2915
2916 return $this->matomoanalytics_module->get_realtime_data();
2917 }
2918
2919 // Matomo exposes a real per-day, per-page report, so unlike Plausible it can feed
2920 // the Content SEO sparklines and per-post totals.
2921 function get_matomo_analytics_pages_daily( $start_date = null, $end_date = null, $paths = null ) {
2922 if ( !isset( $this->matomoanalytics_module ) ) {
2923 return array();
2924 }
2925
2926 return $this->matomoanalytics_module->get_pages_daily( $start_date, $end_date, $paths );
2927 }
2928
2929 function get_matomo_analytics_pages_totals( $start_date = null, $end_date = null ) {
2930 if ( !isset( $this->matomoanalytics_module ) ) {
2931 return array();
2932 }
2933
2934 return $this->matomoanalytics_module->get_pages_totals( $start_date, $end_date );
2935 }
2936
2937 #endregion
2938 }
2939
2940 ?>