719-286-0751 [email protected]

How to Use OpenAI + WP-CLI to Automatically Generate SEO Meta Tags in WordPress

Keeping SEO titles and meta descriptions fresh is one of the simplest ways to improve click-through rates and search visibility. But doing it page-by-page manually can be painful — especially on a growing WordPress site running a page builder.

The good news? With a free OpenAI account and a small MU-plugin, you can automate SEO metadata generation using WP-CLI. In this guide, we’ll walk through how to create an OpenAI API key, store it securely within your WordPress installation, and use a custom script to regenerate your meta fields site-wide.


Step 1: Create Your OpenAI Account

To begin, head to the OpenAI platform:

https://platform.openai.com

Log in (or create a new account), then click your profile icon in the top-right corner and choose “View API Keys.”

Next, click “Create new secret key.” OpenAI will generate a private key that looks something like this:

sk-123abc456def...

Copy this key — you won’t be able to view it again later.


Step 2: Store Your API Key Using putenv() in wp-config.php

Instead of storing your API key in your bash environment, you can securely define it inside wp-config.php using PHP’s putenv function. This makes the key accessible to both WordPress and WP-CLI without changing server configuration.

Edit your wp-config.php file and add this near the top:

putenv('OPENAI_API_KEY=sk-your-api-key-here');

You can test that WP-CLI sees the key by running:

wp eval 'echo getenv("OPENAI_API_KEY");'

If that prints your key, you’re set.


Step 3: Install the MU-Plugin That Generates SEO Metadata

Next, create a file called:

wp-content/mu-plugins/cadence-openai-meta.php

Paste your full working script inside. (The below script already supports putenv, WP-CLI, Divi-rendered HTML fetching, and Yoast meta field updates.)

<?php
/**
 * Plugin Name: Cadence OpenAI Meta
 * Description: Generate SEO titles & meta descriptions using OpenAI via WP-CLI.
 * Author: Cadence Labs
 */

if (defined('WP_CLI') && WP_CLI) {

    class Cadence_OpenAI_Meta_Command {

        /**
         * Generate meta for posts/pages.
         *
         * ## OPTIONS
         *
         * [--post_id=<id>]
         * : Single post ID. If omitted, processes all matching posts.
         *
         * [--post_type=<post_type>]
         * : Post type to process. Default: post
         *
         * [--status=<status>]
         * : Post status to process. Default: publish
         *
         * ## EXAMPLES
         *
         *     # All published posts
         *     wp cadence-openai-meta generate --post_type=post --status=publish
         *
         *     # Single post
         *     wp cadence-openai-meta generate --post_id=7861
         */
        public function generate( $args, $assoc_args ) {

            $post_id   = isset( $assoc_args['post_id'] ) ? absint( $assoc_args['post_id'] ) : 0;
            $post_type = isset( $assoc_args['post_type'] ) ? $assoc_args['post_type'] : 'post';
            $status    = isset( $assoc_args['status'] ) ? $assoc_args['status'] : 'publish';

            if ( $post_id ) {
                $post = get_post( $post_id );
                if ( ! $post ) {
                    WP_CLI::error( "Post {$post_id} not found." );
                }
                $posts = [ $post ];
            } else {
                $query_args = [
                    'post_type'      => $post_type,
                    'post_status'    => $status,
                    'posts_per_page' => -1,
                    'orderby'        => 'ID',
                    'order'          => 'ASC',
                ];
                $posts = get_posts( $query_args );
            }

            if ( empty( $posts ) ) {
                WP_CLI::warning( 'No posts found matching criteria.' );
                return;
            }

            foreach ( $posts as $post ) {
                WP_CLI::log( "Processing post {$post->ID}: {$post->post_title}" );

                $html = $this->get_post_html( $post );
                if ( ! $html ) {
                    WP_CLI::warning( "Could not get HTML for post {$post->ID}, skipping." );
                    continue;
                }

                $meta = $this->get_openai_meta( $html, $post );
                if ( ! $meta || empty( $meta['title'] ) || empty( $meta['description'] ) ) {
                    WP_CLI::warning( "OpenAI returned no usable meta for post {$post->ID}, skipping." );
                    continue;
                }

                // Adjust these meta keys for Yoast / Rank Math / etc as needed
                update_post_meta( $post->ID, '_yoast_wpseo_title',    $meta['title'] );
                update_post_meta( $post->ID, '_yoast_wpseo_metadesc', $meta['description'] );

                WP_CLI::success( "Updated meta for post {$post->ID}" );
            }
        }

        /**
         * Try to get fully rendered HTML.
         * 1) First try HTTP request to the permalink (Divi front-end).
         * 2) If that fails (403, etc.) fall back to filtered post_content.
         */
        private function get_post_html( $post ) {
            $url = get_permalink( $post );

            // Prefer wp_remote_get over raw cURL in WP land
            $response = wp_remote_get( $url, [
                'timeout'   => 20,
                'sslverify' => false, // if your host has funky SSL; set true if possible
                'headers'   => [
                    'User-Agent' => 'Mozilla/5.0 (compatible; CadenceBot/1.0; +https://www.cadence-labs.com)',
                ],
            ] );

            if ( is_wp_error( $response ) ) {
                WP_CLI::warning( "HTTP error for {$url}: " . $response->get_error_message() . " Falling back to the_content()." );
                return $this->render_content_fallback( $post );
            }

            $code = wp_remote_retrieve_response_code( $response );
            if ( 200 !== $code ) {
                WP_CLI::warning( "Non-200 status for {$url}: {$code}. Falling back to the_content()." );
                return $this->render_content_fallback( $post );
            }

            $body = wp_remote_retrieve_body( $response );
            if ( empty( $body ) ) {
                WP_CLI::warning( "Empty body for {$url}. Falling back to the_content()." );
                return $this->render_content_fallback( $post );
            }

            return $body;
        }

        /**
         * Fallback: render Divi shortcodes etc via the_content filters.
         */
        private function render_content_fallback( $post ) {
            setup_postdata( $post );
            $content = apply_filters( 'the_content', $post->post_content );
            wp_reset_postdata();
            return $content;
        }

        /**
         * Call OpenAI to get title + description.
         */
        private function get_openai_meta( $html, $post ) {
            $api_key = getenv( 'OPENAI_API_KEY' ); // change if you used a different env var name

            if ( ! $api_key ) {
                WP_CLI::error( 'Missing OPENAI_API_KEY environment variable.' );
                return false;
            }

            $url = get_permalink( $post );

            // Keep it SHORT – summarize the page for meta only
            $prompt = "You are an SEO assistant. Analyze the following rendered HTML for a web page.\n\n"
                . "URL: {$url}\n\n"
                . "Write:\n"
                . "1) An SEO title tag (max 60 characters) that is keyword-rich and natural.\n"
                . "2) An SEO meta description (max 155 characters) that is compelling and includes important keywords.\n\n"
                . "Return ONLY valid JSON like the below and do not wrap the JSON in markdown or backticks:\n"
                . "{\n"
                . "  \"title\": \"...\",\n"
                . "  \"description\": \"...\"\n"
                . "}";

            $payload = [
                'model'    => 'gpt-4.1-mini', // or another model you prefer
                'messages' => [
                    [ 'role' => 'system', 'content' => 'You are a senior SEO specialist.' ],
                    [ 'role' => 'user',   'content' => $prompt . "\n\nHTML:\n" . mb_substr( $html, 0, 12000 ) ],
                ],
                'temperature' => 0.4,
            ];

            $ch = curl_init( 'https://api.openai.com/v1/chat/completions' );
            curl_setopt_array( $ch, [
                CURLOPT_POST           => true,
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER     => [
                    'Content-Type: application/json',
                    'Authorization: Bearer ' . $api_key,
                ],
                CURLOPT_POSTFIELDS     => json_encode( $payload ),
                CURLOPT_TIMEOUT        => 60,
            ] );

            $response_body = curl_exec( $ch );
            $http_code     = curl_getinfo( $ch, CURLINFO_HTTP_CODE );

            if ( $response_body === false ) {
                $error = curl_error( $ch );
                curl_close( $ch );
                WP_CLI::warning( "cURL error calling OpenAI: {$error}" );
                return false;
            }

            curl_close( $ch );

            if ( $http_code == 429 ) {
                WP_CLI::error( "OpenAI rate limit / quota exceeded (HTTP 429). Check your plan and billing in the OpenAI dashboard." );
                return false;
            }

            if ( $http_code < 200 || $http_code >= 300 ) {
                WP_CLI::warning( "OpenAI HTTP error code: {$http_code} Response: {$response_body}" );
                return false;
            }

            $decoded = json_decode( $response_body, true );
            if ( ! isset( $decoded['choices'][0]['message']['content'] ) ) {
                WP_CLI::warning( 'OpenAI returned no message content.' );
                return false;
            }

            $content = trim( $decoded['choices'][0]['message']['content'] );

            // Model is instructed to return JSON; try parsing it
            $meta = json_decode( $content, true );
            if ( ! is_array( $meta ) ) {
                WP_CLI::warning( 'Could not decode OpenAI JSON response: ' . $content );
                return false;
            }

            $title       = isset( $meta['title'] ) ? trim( $meta['title'] ) : '';
            $description = isset( $meta['description'] ) ? trim( $meta['description'] ) : '';

            if ( ! $title || ! $description ) {
                WP_CLI::warning( 'OpenAI JSON missing title or description.' );
                return false;
            }

            return [
                'title'       => $title,
                'description' => $description,
            ];
        }
    }

    // Register the command: wp cadence-openai-meta generate ...
    WP_CLI::add_command( 'cadence-openai-meta', 'Cadence_OpenAI_Meta_Command' );
}

Because it lives in mu-plugins, the command is always loaded — no plugin activation required.

Once installed, the WP-CLI command becomes:

wp cadence-openai-meta generate

Or regenerate metadata for one specific post:

wp cadence-openai-meta generate --post_id=123

The script works by:

  • Fetching the fully rendered page (Divi output included)
  • Extracting readable text content
  • Sending it to OpenAI with a structured prompt
  • Receiving an SEO title + meta description
  • Writing them into Yoast or your SEO plugin’s fields

Step 4: Regenerate Metadata Anytime

Want to refresh all published posts?

wp cadence-openai-meta generate --post_type=post --status=publish

Want to refresh pages instead?

wp cadence-openai-meta generate --post_type=page

Publishing new content? Generate metadata instantly:

wp cadence-openai-meta generate --post_id=7890

Since the script analyzes your real rendered content, you get accurate, context-aware metadata — not generic fluff.


Final Thoughts

With an OpenAI API key and this MU-plugin, you’ve essentially turned your command line into an on-demand SEO assistant. Whether you’re refreshing old posts, optimizing new content, or updating hundreds of pages at once, automating metadata generation can eliminate hours of manual work.

And because everything runs through WordPress and WP-CLI, it stays fast, predictable, and fully under your control.


Need Help Implementing This?

Cadence Labs is a Colorado-based ecommerce and web development agency specializing in Shopify, Magento, WordPress, and custom engineering solutions. If you’d like help integrating OpenAI into your site, improving SEO, or optimizing performance — we’d love to chat.

Contact Cadence Labs →

Install our webapp on your iPhone! Tap and then Add to homescreen.
Share This