class-restai-seo.php
1 <?php 2 /** 3 * SEO meta generation. Outputs structured data (title, description, focus 4 * keyphrase, suggested tags) and writes it into Yoast / Rank Math fields when 5 * those plugins are detected. 6 * 7 * @package RESTai 8 */ 9 10 namespace RESTai; 11 12 if ( ! defined( 'ABSPATH' ) ) { 13 exit; 14 } 15 16 class SEO { 17 18 /** @var Client */ 19 private $client; 20 21 public function __construct( Client $client ) { 22 $this->client = $client; 23 } 24 25 /** 26 * Generate SEO meta for a given post id and write it to the appropriate 27 * SEO plugin's fields when present. 28 * 29 * @param int $post_id 30 * @return array|\WP_Error 31 */ 32 public function generate_for_post( $post_id ) { 33 $post = get_post( $post_id ); 34 if ( ! $post ) { 35 return new \WP_Error( 'restai_no_post', __( 'Post not found.', 'restai' ) ); 36 } 37 38 $plugin = Plugin::instance(); 39 $project_id = $plugin->provisioner->project_for( 'seo_assistant' ); 40 if ( $project_id <= 0 ) { 41 return new \WP_Error( 'restai_no_project', __( 'SEO project not configured.', 'restai' ) ); 42 } 43 44 $body = wp_strip_all_tags( $post->post_content ); 45 $body = mb_substr( $body, 0, 4000 ); 46 47 // Carry the JSON contract in the user prompt so we don't depend on the 48 // project's system prompt being in sync. 49 $prompt = "Return ONLY a JSON object (no markdown, no preamble, no commentary) with this exact shape:\n" . 50 "{\n" . 51 ' "meta_title": "max 60 chars",' . "\n" . 52 ' "meta_description": "max 155 chars",' . "\n" . 53 ' "focus_keyphrase": "1-3 words",' . "\n" . 54 ' "suggested_tags": ["tag1", "tag2", "..."]' . "\n" . 55 "}\n\n" . 56 "Source post:\nTITLE: " . $post->post_title . "\n\nCONTENT:\n" . $body; 57 58 $answer = $this->client->ask( $project_id, $prompt ); 59 if ( is_wp_error( $answer ) ) { 60 return $answer; 61 } 62 63 $meta = $this->parse_json( $answer ); 64 if ( empty( $meta ) ) { 65 $snippet = mb_substr( (string) $answer, 0, 200 ); 66 return new \WP_Error( 67 'restai_seo_parse', 68 sprintf( 69 /* translators: %s response snippet */ 70 __( 'SEO response was not valid JSON. First 200 chars: %s', 'restai' ), 71 $snippet 72 ) 73 ); 74 } 75 76 // Write into known SEO plugin fields where available. 77 if ( ! empty( $meta['meta_title'] ) ) { 78 update_post_meta( $post_id, '_yoast_wpseo_title', $meta['meta_title'] ); 79 update_post_meta( $post_id, 'rank_math_title', $meta['meta_title'] ); 80 } 81 if ( ! empty( $meta['meta_description'] ) ) { 82 update_post_meta( $post_id, '_yoast_wpseo_metadesc', $meta['meta_description'] ); 83 update_post_meta( $post_id, 'rank_math_description', $meta['meta_description'] ); 84 } 85 if ( ! empty( $meta['focus_keyphrase'] ) ) { 86 update_post_meta( $post_id, '_yoast_wpseo_focuskw', $meta['focus_keyphrase'] ); 87 update_post_meta( $post_id, 'rank_math_focus_keyword', $meta['focus_keyphrase'] ); 88 } 89 if ( ! empty( $meta['suggested_tags'] ) && is_array( $meta['suggested_tags'] ) ) { 90 wp_set_post_tags( $post_id, $meta['suggested_tags'], true ); 91 } 92 93 return $meta; 94 } 95 96 /** 97 * Tolerantly parse a JSON response from the LLM (it sometimes wraps in 98 * markdown fences). 99 */ 100 private function parse_json( $text ) { 101 $text = trim( (string) $text ); 102 if ( '' === $text ) { 103 return null; 104 } 105 106 // 1. Try ```json ... ``` fenced blocks first (most LLM-friendly). 107 if ( preg_match( '/```(?:json)?\s*(\{.*?\})\s*```/s', $text, $m ) ) { 108 $data = json_decode( $m[1], true ); 109 if ( is_array( $data ) ) { 110 return $data; 111 } 112 } 113 114 // 2. Strip generic fences if present. 115 $stripped = preg_replace( '/^```(?:[a-z]+)?\s*/i', '', $text ); 116 $stripped = preg_replace( '/\s*```$/', '', $stripped ); 117 118 // 3. Try the cleaned text as-is. 119 $data = json_decode( $stripped, true ); 120 if ( is_array( $data ) ) { 121 return $data; 122 } 123 124 // 4. Fallback: find the longest balanced {...} substring and try that. 125 $start = strpos( $stripped, '{' ); 126 while ( false !== $start ) { 127 $depth = 0; 128 $len = strlen( $stripped ); 129 for ( $i = $start; $i < $len; $i++ ) { 130 if ( $stripped[ $i ] === '{' ) { 131 $depth++; 132 } elseif ( $stripped[ $i ] === '}' ) { 133 $depth--; 134 if ( 0 === $depth ) { 135 $candidate = substr( $stripped, $start, $i - $start + 1 ); 136 $data = json_decode( $candidate, true ); 137 if ( is_array( $data ) ) { 138 return $data; 139 } 140 break; 141 } 142 } 143 } 144 $start = strpos( $stripped, '{', $start + 1 ); 145 } 146 147 return null; 148 } 149 }