PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
← All changes | wp-mcp-server/tools/class-mcp-tool-post-meta.php +134 -87 2.6.21 → 2.7.1 View file →
@@ -81,9 +81,10 @@
81 81
82 82 // hreflang / language alternates: value must be a JSON array of
83 83 // {lang, url} objects (with optional region). Parse from the raw
84 84 // param to avoid textarea sanitization mangling the JSON, validate
85 - // shape, and re-encode to canonical form before storing.
85 + // shape AND values (ISO language codes, absolute http(s) URLs),
86 + // normalise, and re-encode to canonical form before storing.
86 87 if ($meta_key === '_metasync_hreflang') {
87 88 $raw_value = isset($params['meta_value']) ? (string) $params['meta_value'] : '';
88 89 $decoded = json_decode($raw_value, true);
89 90 if (!is_array($decoded)) {
@@ -88,19 +89,40 @@
88 89 $decoded = json_decode($raw_value, true);
89 90 if (!is_array($decoded)) {
90 91 throw new Exception("_metasync_hreflang must be a JSON array");
91 92 }
93 + $clean = [];
92 94 foreach ($decoded as $entry) {
93 95 if (!is_array($entry) || !isset($entry['lang']) || !isset($entry['url'])) {
94 96 throw new Exception("Each hreflang entry must have 'lang' and 'url' keys");
95 97 }
98 + $normalized = Metasync_Hreflang_Output::normalize_entry($entry);
99 + if (!$normalized['valid']) {
100 + throw new Exception(sprintf(
101 + "Invalid hreflang language code '%s' — use an ISO code like 'en', 'en-US' (hyphen, not underscore) or 'x-default'",
102 + is_scalar($entry['lang']) ? (string) $entry['lang'] : gettype($entry['lang'])
103 + ));
104 + }
105 + // Sanitize each URL to strip javascript: and other unsafe protocols.
106 + $url = esc_url_raw(trim((string) $entry['url']));
107 + if (!Metasync_Hreflang_Output::is_absolute_http_url($url)) {
108 + throw new Exception(sprintf(
109 + "hreflang URL '%s' must be an absolute http(s) URL (relative paths are not valid hreflang hrefs)",
110 + (string) $entry['url']
111 + ));
112 + }
113 + $clean[] = [
114 + 'lang' => $normalized['lang'],
115 + 'region' => $normalized['region'],
116 + 'url' => $url,
117 + ];
96 118 }
97 - // Sanitize each URL to strip javascript: and other unsafe protocols.
98 - foreach ($decoded as &$entry) {
99 - $entry['url'] = esc_url_raw($entry['url']);
100 - }
101 - unset($entry);
102 - $meta_value = wp_json_encode($decoded);
119 + // update_metadata() runs wp_unslash() on the value; combined with
120 + // the default \uXXXX escaping this corrupted non-ASCII URLs
121 + // (…/über-uns/ stored as …/u00fcber-uns/). Encoding without the
122 + // escapes and passing slashed data — the same contract the REST
123 + // path uses — keeps the stored JSON byte-faithful.
124 + $meta_value = wp_slash(wp_json_encode($clean, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
103 125 }
104 126
105 127 // Validate _metasync_robots_advanced JSON
106 128 if ($meta_key === '_metasync_robots_advanced') {
@@ -167,9 +189,15 @@
167 189 // so cast for type-safe comparison against the integer $meta_value above.
168 190 if ($meta_key === '_metasync_primary_category') {
169 191 $current_value = (int) $current_value;
170 192 }
171 - if ($current_value === $meta_value) {
193 + // The hreflang payload is stored slashed (wp_slash) so it survives
194 + // update_metadata()'s wp_unslash(); the stored value is therefore the
195 + // UNSLASHED form. Compare against that, or an identical re-write would
196 + // look like a change, run update_post_meta(), come back false ("value
197 + // unchanged") and wrongly throw below.
198 + $compare_value = $meta_key === '_metasync_hreflang' ? wp_unslash($meta_value) : $meta_value;
199 + if ($current_value === $compare_value) {
172 200 // Value already matches — return success without update
173 201 return $this->success([
174 202 'post_id' => $post_id,
175 203 'meta_key' => $meta_key,
@@ -401,10 +429,11 @@
401 429 /**
402 430 * Get Hreflang Links Tool
403 431 *
404 432 * Returns all hreflang entries for a post (manual + WPML auto-detected),
405 - * validates that each referenced URL returns HTTP 200, and flags a missing
406 - * x-default entry.
433 + * validates that each referenced URL returns HTTP 200 and links back to
434 + * this post (hreflang reciprocity), and flags a missing x-default entry
435 + * and a missing self-reference.
407 436 */
408 437 class MCP_Tool_Get_Hreflang_Links extends MCP_Tool_Base {
409 438
410 439 public function get_name() {
@@ -411,9 +440,9 @@
411 440 return 'wordpress_get_hreflang_links';
412 441 }
413 442
414 443 public function get_description() {
415 - return 'Get all hreflang entries for a post (manual + WPML auto-detected), validate each URL returns HTTP 200, and flag a missing x-default entry';
444 + return 'Get all hreflang entries for a post (manual + WPML auto-detected), validate each URL returns HTTP 200 and links back to this post (reciprocity), and flag missing x-default / self-reference entries';
416 445 }
417 446
418 447 public function get_input_schema() {
419 448 return [
@@ -439,26 +468,44 @@
439 468
440 469 $manual_entries = $this->get_manual_entries($post_id);
441 470 $wpml_entries = $this->get_wpml_entries($post_id, $post);
442 471
443 - // Merge: auto-detected first, then manual entries override by
444 - // lang+region collision key.
472 + // Merge: auto-detected first, then manual entries override by the
473 + // normalised language-code collision key. Rows with an invalid code
474 + // (empty key) are kept verbatim instead of collapsing into one —
475 + // the audit report is where they become visible.
445 476 $by_key = [];
477 + $invalid = [];
446 478 foreach ($wpml_entries as $entry) {
447 - $by_key[$this->collision_key($entry)] = $entry;
479 + $key = Metasync_Hreflang_Output::lang_code($entry);
480 + if ($key === '') {
481 + $invalid[] = $entry;
482 + } else {
483 + $by_key[$key] = $entry;
484 + }
448 485 }
449 486 foreach ($manual_entries as $entry) {
450 - $by_key[$this->collision_key($entry)] = $entry;
487 + $key = Metasync_Hreflang_Output::lang_code($entry);
488 + if ($key === '') {
489 + $invalid[] = $entry;
490 + } else {
491 + $by_key[$key] = $entry;
492 + }
451 493 }
452 - $entries = array_values($by_key);
494 + $entries = array_merge(array_values($by_key), $invalid);
453 495
454 - // Validate each URL returns HTTP 200 (try HEAD first, fall back to
455 - // GET on 405 Method Not Allowed).
496 + $self_url = get_permalink($post_id);
497 +
498 + // Validate each URL: HTTP 200 (HEAD first, GET on 405) and — when
499 + // the target is an external alternate — a return link to this post
500 + // (hreflang must be bidirectional; Google ignores one-way clusters).
456 501 $has_x_default = false;
502 + $has_self_reference = false;
457 503 foreach ($entries as &$entry) {
458 504 $url = isset($entry['url']) ? $entry['url'] : '';
459 505 $status = null;
460 506 $error = null;
507 + $return_link = null;
461 508
462 509 if (!empty($url)) {
463 510 $response = wp_remote_head($url, ['timeout' => 5, 'sslverify' => false]);
464 511 if (is_wp_error($response)) {
@@ -474,8 +521,18 @@
474 521 $status = (int) wp_remote_retrieve_response_code($response);
475 522 }
476 523 }
477 524 }
525 +
526 + // Reciprocity: fetch the alternate page and check whether
527 + // its hreflang set contains this post's URL.
528 + if ($error === null && $status === 200 && !empty($self_url) && $url !== $self_url) {
529 + $response = wp_remote_get($url, ['timeout' => 5, 'sslverify' => false]);
530 + if (!is_wp_error($response)) {
531 + $body = (string) wp_remote_retrieve_body($response);
532 + $return_link = self::body_links_back($body, $self_url);
533 + }
534 + }
478 535 }
479 536
480 537 $entry['http_status'] = $status;
481 538 $entry['http_ok'] = ($status === 200);
@@ -481,34 +538,67 @@
481 538 $entry['http_ok'] = ($status === 200);
482 539 if ($error !== null) {
483 540 $entry['http_error'] = $error;
484 541 }
542 + if ($return_link !== null) {
543 + $entry['return_link'] = $return_link;
544 + }
485 545
486 - if (isset($entry['lang']) && $entry['lang'] === 'x-default') {
546 + if (Metasync_Hreflang_Output::lang_code($entry) === 'x-default') {
487 547 $has_x_default = true;
488 548 }
549 + if (!empty($self_url) && $url === $self_url) {
550 + $has_self_reference = true;
551 + }
489 552 }
490 553 unset($entry);
491 554
492 - $missing_x_default = !$has_x_default;
493 -
494 555 return $this->success([
495 - 'post_id' => $post_id,
496 - 'post_title' => $post->post_title,
497 - 'entries' => $entries,
498 - 'missing_x_default' => $missing_x_default,
556 + 'post_id' => $post_id,
557 + 'post_title' => $post->post_title,
558 + 'self_url' => $self_url,
559 + 'entries' => $entries,
560 + 'missing_x_default' => !$has_x_default,
561 + 'missing_self_reference' => !$has_self_reference,
499 562 ]);
500 563 }
501 564
502 565 /**
566 + * True when the page body contains a hreflang link pointing back at the
567 + * given URL.
568 + *
569 + * @param string $body HTML body of the alternate page.
570 + * @param string $self_url This post's permalink.
571 + * @return bool
572 + */
573 + private static function body_links_back($body, $self_url) {
574 + if (!preg_match_all('/<link[^>]+hreflang\s*=\s*[\'"]([^\'"]*)[\'"][^>]*>/i', (string) $body, $links)) {
575 + return false;
576 + }
577 + foreach ($links[0] as $tag) {
578 + if (stripos($tag, 'rel="alternate"') === false && stripos($tag, "rel='alternate'") === false) {
579 + continue;
580 + }
581 + if (strpos($tag, $self_url) !== false) {
582 + return true;
583 + }
584 + }
585 + return false;
586 + }
587 +
588 + /**
503 589 * Read manual hreflang entries from `_metasync_hreflang` post meta.
504 590 *
591 + * Rows are normalised and carry their final lang_code plus a valid flag,
592 + * so an invalid stored row is visible in the report (the emitter skips
593 + * it silently — this is the tool that surfaces it).
594 + *
505 595 * @param int $post_id Post ID.
506 596 * @return array
507 597 */
508 598 private function get_manual_entries($post_id) {
509 599 $raw = get_post_meta($post_id, '_metasync_hreflang', true);
510 - if (empty($raw)) {
600 + if (empty($raw) || !is_string($raw)) {
511 601 return [];
512 602 }
513 603 $decoded = json_decode($raw, true);
514 604 if (!is_array($decoded)) {
@@ -518,13 +608,16 @@
518 608 foreach ($decoded as $entry) {
519 609 if (!is_array($entry)) {
520 610 continue;
521 611 }
612 + $normalized = Metasync_Hreflang_Output::normalize_entry($entry);
522 613 $entries[] = [
523 - 'lang' => isset($entry['lang']) ? (string) $entry['lang'] : '',
524 - 'region' => isset($entry['region']) ? (string) $entry['region'] : '',
525 - 'url' => isset($entry['url']) ? (string) $entry['url'] : '',
526 - 'source' => 'manual',
614 + 'lang' => $normalized['lang'],
615 + 'region' => $normalized['region'],
616 + 'url' => $normalized['url'],
617 + 'lang_code' => $normalized['lang_code'],
618 + 'valid' => $normalized['valid'],
619 + 'source' => 'manual',
527 620 ];
528 621 }
529 622 return $entries;
530 623 }
@@ -529,10 +622,12 @@
529 622 return $entries;
530 623 }
531 624
532 625 /**
533 - * Build WPML auto-detected entries by querying the icl_translations table.
534 - * Mirrors the logic in Metasync_Hreflang_Output::get_wpml_entries.
626 + * Build WPML auto-detected entries via the shared implementation in
627 + * Metasync_Hreflang_Output::get_wpml_entries() (publish-only rows,
628 + * x-default synthesis) so the audit reports exactly what the front end
629 + * emits.
535 630 *
536 631 * @param int $post_id Post ID.
537 632 * @param WP_Post $post Post object (already verified).
538 633 * @return array
@@ -540,64 +635,16 @@
540 635 private function get_wpml_entries($post_id, $post) {
541 636 if (!defined('ICL_SITEPRESS_VERSION')) {
542 637 return [];
543 638 }
544 - global $wpdb;
545 - $table = $wpdb->prefix . 'icl_translations';
546 - $element_type = 'post_' . $post->post_type;
547 639
548 - $trid = $wpdb->get_var($wpdb->prepare(
549 - "SELECT trid FROM {$table} WHERE element_id = %d AND element_type = %s LIMIT 1",
550 - $post_id,
551 - $element_type
552 - ));
553 - if (empty($trid)) {
554 - return [];
555 - }
640 + $entries = (new Metasync_Hreflang_Output())->get_wpml_entries($post_id);
556 641
557 - $rows = $wpdb->get_results($wpdb->prepare(
558 - "SELECT language_code, element_id FROM {$table} WHERE trid = %d",
559 - $trid
560 - ));
561 - if (empty($rows)) {
562 - return [];
642 + $out = [];
643 + foreach ($entries as $entry) {
644 + $normalized = Metasync_Hreflang_Output::normalize_entry($entry);
645 + $normalized['source'] = 'wpml';
646 + $out[] = $normalized;
563 647 }
564 -
565 - $default_lang = apply_filters('wpml_default_language', null);
566 - if (empty($default_lang)) {
567 - $default_lang = get_option('wpml_default_language');
568 - }
569 -
570 - $entries = [];
571 - foreach ($rows as $row) {
572 - $permalink = get_permalink((int) $row->element_id);
573 - if (empty($permalink)) {
574 - continue;
575 - }
576 - $entries[] = [
577 - 'lang' => (string) $row->language_code,
578 - 'region' => '',
579 - 'url' => $permalink,
580 - 'source' => 'wpml',
581 - ];
582 - if (!empty($default_lang) && $row->language_code === $default_lang) {
583 - $entries[] = [
584 - 'lang' => 'x-default',
585 - 'region' => '',
586 - 'url' => $permalink,
587 - 'source' => 'wpml',
588 - ];
589 - }
590 - }
591 - return $entries;
592 - }
593 -
594 - /**
595 - * Collision key in the form `lang-region` (or just `lang` when region
596 - * is empty) for de-duplicating entries.
597 - */
598 - private function collision_key(array $entry) {
599 - $lang = isset($entry['lang']) ? (string) $entry['lang'] : '';
600 - $region = isset($entry['region']) ? (string) $entry['region'] : '';
601 - return $region !== '' ? $lang . '-' . $region : $lang;
648 + return $out;
602 649 }
603 650 }