/ wordpress / restai / includes / class-restai-seo.php
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  }