WP Tuts Hub

WordPress Tutorials, Themes and Plugins Hub
Advertisement

Browse

  • Home
  • Categories
    • WordPress Troubleshooting
    • WordPress Tips and Tricks
    • SSH (Secure Shell)
    • WordPress Insights
    • WordPress Common Facts
    • Securing WordPress
  • Series Tutorials
    • Advanced WordPress Topic
  • Privacy Policy
  • Terms of Use
  • Contact Me
  • About WP Tuts Hub
Home/Advanced WordPress Topic/Bulk-Generate WooCommerce Product Descriptions with the OpenAI Batch API and a WP-CLI Review Queue
Advanced WordPress Topic
October 26, 2026

Bulk-Generate WooCommerce Product Descriptions with the OpenAI Batch API and a WP-CLI Review Queue

Bulk-Generate WooCommerce Product Descriptions with the OpenAI Batch API and a WP-CLI Review Queue

Why the Batch API and not a loop of synchronous calls

Writing a script that loops over 900 products and calls chat/completions 900 times is the fastest way to hit a rate limit, time out on a shared host, and burn through tokens with no audit trail. The OpenAI Batch API works differently: you upload one JSONL file containing every request, the job runs asynchronously (a 24-hour completion window), and you poll for the result. It is roughly half the price per token compared to interactive calls, and it survives a dropped SSH connection because nothing is held open.

For WooCommerce this maps neatly onto a review workflow. Generation is slow and cheap, so it belongs in a batch. Approval is fast and needs a human, so it belongs in the admin. The WP-CLI command does the boring part; a capability-gated screen does the judgement part.

The critical design rule: never write generated text straight into post_content. Stage it in post meta, let an editor approve it, and only then promote it — via wp_update_post(), so WordPress runs its own sanitisation and revision pipeline.

Prerequisites

  • WordPress 6.4 or later and WooCommerce 8+. HPOS is fine; we only touch post_content, which stays on the posts table.
  • WP-CLI 2.7+ installed and working over SSH or a local terminal. Verify with wp cli version.
  • An OpenAI organisation account with a project API key that has the files and batches scopes enabled.
  • PHP 8.0+ with the curl extension. Every mainstream host has it; check with php -m | grep curl.
  • A staging copy of the database. Run the first pass there, not on production.

Architecture at a glance

  1. enqueue — select products with an empty description, build a .jsonl file, upload it, create the batch, record the batch ID.
  2. poll — check each recorded batch; when it completes, download the output file and write each response to _wc_bd_pending meta.
  3. Admin screen — list products with pending copy, preview it, approve (promote to post_content) or discard.

Step 1 — Keep the API key out of the database

Storing the key in an option means it round-trips through the database, shows up in any migration, and gets exported by backup plugins. A constant in wp-config.php that reads from an environment variable keeps it out of the DB and out of version control. Define it above the /* That's all, stop editing! */ line, and never commit the file.

// wp-config.php — above "That's all, stop editing".
// Set OPENAI_API_KEY in your shell/server environment, not in version control.
define( 'WC_OPENAI_API_KEY', getenv( 'OPENAI_API_KEY' ) ?: '' );

// Hard-stop the command if the key is missing, rather than failing mid-batch.
if ( defined( 'WP_CLI' ) && WP_CLI && ( ! defined( 'WC_OPENAI_API_KEY' ) || '' === WC_OPENAI_API_KEY ) ) {
    WP_CLI::warning( 'WC_OPENAI_API_KEY is not set. WC batch generation will refuse to run.' );
}

Read the key only inside the command class, through a single getter, so you have one place to add a filter later if you move to a secret manager. Do not echo it in log lines — the OpenAI error responses never contain it, but your own WP_CLI::log() calls easily could.

Step 2 — The enqueue command: select, build JSONL, upload

The selection query is the part people get wrong. WooCommerce stores the long description in post_content, so a product is a candidate when post_content is empty or whitespace. You cannot filter on that with a meta_query, so pull IDs with a reasonable criteria first and trim in PHP. Also exclude anything already queued with NOT EXISTS on the batch meta, otherwise a second run re-bills the same products.

Every JSONL line needs a stable custom_id — the only field OpenAI echoes back. product-{ID} is enough and is trivially reversible.

private function build_jsonl( array $ids, string $path ): int {
    $handle = fopen( $path, 'w' );
    if ( ! $handle ) {
        WP_CLI::error( 'Cannot open ' . $path . ' for writing.' );
    }

    $written = 0;
    foreach ( $ids as $id ) {
        $product = wc_get_product( $id );
        if ( ! $product ) {
            continue;
        }

        $categories = wp_strip_all_tags( wc_get_product_category_list( $id, ', ' ) );
        $attributes = [];
        foreach ( $product->get_attributes() as $attribute ) {
            if ( $attribute->get_visible() ) {
                $attributes[] = $attribute->get_name() . ': ' . implode( ', ', $attribute->get_options() );
            }
        }
        $attributes = wp_strip_all_tags( implode( '; ', $attributes ) );
        $prompt     = sprintf(
            'Write a single-paragraph product description of about 110 words for "%1$s". Categories: %2$s. Key attributes: %3$s. Use British English, no exclamation marks, no marketing cliches, no pricing claims.',
            $product->get_name(),
            $categories ?: 'uncategorised',
            $attributes ?: wp_strip_all_tags( $product->get_short_description() ) ?: 'none provided'
        );

        $row = [
            'custom_id' => 'product-' . $id,
            'method'    => 'POST',
            'url'       => '/v1/chat/completions',
            'body'      => [
                'model'       => 'gpt-4o-mini',
                'temperature' => 0.4,
                'max_tokens'  => 400,
                'messages'    => [
                    [
                        'role'    => 'system',
                        'content' => 'You write plain-text WooCommerce product copy. Return the description only, with no headings, labels, or commentary.',
                    ],
                    [
                        'role'    => 'user',
                        'content' => $prompt,
                    ],
                ],
            ],
        ];

        $line = wp_json_encode( $row, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES );
        if ( false === fwrite( $handle, $line . "\n" ) ) {
            WP_CLI::warning( 'Skipped product ' . $id . ' — write failed.' );
            continue;
        }
        $written++;
    }

    fclose( $handle );
    return $written;
}

Note the temperature of 0.4. Product copy should be consistent across a catalogue; the default of 1.0 produces wildly different tones between a mug and a mug.

The upload endpoint requires multipart/form-data, which wp_remote_post() does not handle well. Use cURL directly for this one call — it is the honest solution and every host that runs WooCommerce has the extension.

private function upload_batch_file( string $path ): string {
    $key = $this->get_api_key();
    $ch  = curl_init( 'https://api.openai.com/v1/files' );

    curl_setopt_array( $ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST           => true,
        CURLOPT_TIMEOUT        => 180,
        CURLOPT_HTTPHEADER     => [ 'Authorization: Bearer ' . $key ],
        CURLOPT_POSTFIELDS     => [
            'purpose' => 'batch',
            'file'    => new CURLFile( $path, 'application/jsonl', 'wc-descriptions.jsonl' ),
        ],
    ] );

    $raw    = curl_exec( $ch );
    $status = curl_getinfo( $ch, CURLINFO_HTTP_CODE );
    curl_close( $ch );

    $data = json_decode( (string) $raw, true );
    if ( 200 !== $status || empty( $data['id'] ) ) {
        $this->log( 'File upload failed: ' . $raw );
        WP_CLI::error( 'Upload failed with HTTP ' . $status . '. See the WC log for the body.' );
    }
    return $data['id'];
}

Once you have the file ID, create the batch with a plain JSON POST to /v1/batches — body input_file_id, endpoint: "/v1/chat/completions", completion_window: "24h". Store the returned batch ID in an option array, not in post meta; a batch maps to many products and you want a single registry to iterate.

Step 3 — Poll, ingest, and log

Polling is deliberately dumb: loop over the registry, GET each batch, act on the status. Batch statuses move through validating, in_progress, finalizing, completed, and can end at failed, expired, or cancelled. Only completed gets ingested; remove the batch from the registry only after ingest succeeds, so a crash mid-ingest is retryable.

public function poll(): void {
    $registry = (array) get_option( 'wc_bd_batches', [] );
    if ( ! $registry ) {
        WP_CLI::warning( 'No batches on record.' );
        return;
    }

    foreach ( $registry as $index => $batch ) {
        $data   = $this->api_get( 'batches/' . $batch['id'] );
        $status = $data['status'] ?? 'unknown';
        $this->log( sprintf( 'Batch %s is %s (%s/%s done)', $batch['id'], $status,
            $data['request_counts']['completed'] ?? 0,
            $data['request_counts']['total'] ?? 0
        ) );

        if ( 'completed' !== $status ) {
            continue;
        }

        $body = $this->api_get_raw( 'files/' . $data['output_file_id'] . '/content' );
        $this->ingest( $body );
        unset( $registry[ $index ] );
        update_option( 'wc_bd_batches', array_values( $registry ) );
    }
}

private function ingest( string $body ): void {
    $staged = 0;
    foreach ( preg_split( '/\r?\n/', $body ) as $line ) {
        $row = json_decode( $line, true );
        if ( ! is_array( $row ) || empty( $row['custom_id'] ) ) {
            continue;
        }

        $post_id = (int) str_replace( 'product-', '', $row['custom_id'] );
        if ( ! $post_id || 'product' !== get_post_type( $post_id ) ) {
            continue;
        }

        $text = $row['response']['body']['choices'][0]['message']['content'] ?? '';
        if ( '' === trim( $text ) ) {
            continue;
        }

        update_post_meta( $post_id, '_wc_bd_pending', wp_kses_post( wpautop( $text ) ) );
        update_post_meta( $post_id, '_wc_bd_batch_id', sanitize_text_field( $row['id'] ?? '' ) );
        update_post_meta( $post_id, '_wc_bd_generated_at', current_time( 'mysql' ) );
        $staged++;
    }
    $this->log( sprintf( 'Staged %d descriptions as pending review.', $staged ) );
}

private function log( string $message ): void {
    if ( function_exists( 'wc_get_logger' ) ) {
        wc_get_logger()->info( $message, [ 'source' => 'wc-bd' ] );
    }
    WP_CLI::log( $message );
}

wp_kses_post() plus wpautop() preserves paragraph breaks while stripping anything the model hallucinates — including stray <script> tags or Markdown fences. This is why sanitising at ingest time, not approval time, matters: the staged meta is already safe to render in the admin preview.

Step 4 — Dry-run mode and the CLI surface

The dry-run flag writes the JSONL to wp-content/uploads/wc-bd/ and prints a one-line preview of the first three prompts, then exits before any network call. This is how you validate prompt wording without spending money. Guard the directory with an index.php and a .htaccess deny, or write to the system temp dir instead.

# Build and upload a batch of up to 100 empty descriptions
wp wc-batch enqueue --limit=100 --user=admin

# Same selection, no upload — inspect the JSONL first
wp wc-batch enqueue --limit=100 --dry-run

# Check batch progress and stage finished results
wp wc-batch poll

# Run poll every five minutes from cron, logging elsewhere
*/5 * * * * cd /var/www/site && wp wc-batch poll >> /var/log/wc-batch.log 2>&1

Inside enqueue(), call WP_CLI::confirm( 'Upload the batch? This incurs API cost.' ) unless --yes is passed. WP-CLI sets --yes automatically in non-interactive contexts, so cron still works without a prompt.

Step 5 — The admin review screen

Approval must clear three gates: the edit_products capability, a nonce, and a second look at the product’s current state (someone may have written the description manually while the batch ran). Promoting through wp_update_post() rather than a direct $wpdb write means revisions are created, caches are flushed, and WooCommerce’s own hooks fire.

add_action( 'admin_menu', static function (): void {
    add_submenu_page(
        'woocommerce',
        'Description Review',
        'Description Review',
        'edit_products',
        'wc-bd-review',
        'wc_bd_render_review'
    );
} );

function wc_bd_render_review(): void {
    if ( ! current_user_can( 'edit_products' ) ) {
        wp_die( esc_html__( 'You do not have permission to review product copy.', 'wc-bd' ) );
    }

    if ( isset( $_POST['wc_bd_product'], $_POST['wc_bd_action'], $_POST['wc_bd_nonce'] ) ) {
        check_admin_referer( 'wc_bd_review', 'wc_bd_nonce' );
        $id     = absint( $_POST['wc_bd_product'] );
        $action = sanitize_key( wp_unslash( $_POST['wc_bd_action'] ) );

        if ( 'product' !== get_post_type( $id ) ) {
            wp_die( esc_html__( 'Invalid product.', 'wc-bd' ) );
        }

        if ( 'approve' === $action ) {
            $staged = get_post_meta( $id, '_wc_bd_pending', true );
            if ( $staged ) {
                wp_update_post( [
                    'ID'           => $id,
                    'post_content' => wp_kses_post( $staged ),
                ] );
            }
        }

        delete_post_meta( $id, '_wc_bd_pending' );
        wc_delete_product_transients( $id );
        echo '<div class="notice notice-success"><p>Updated.</p></div>';
    }

    $pending = get_posts( [
        'post_type'      => 'product',
        'post_status'    => 'any',
        'posts_per_page' => 25,
        'meta_key'       => '_wc_bd_pending',
        'fields'         => 'ids',
    ] );

    echo '<div class="wrap"><h2>Pending Descriptions</h2>';
    echo '<p>' . esc_html( sprintf( '%d products awaiting review.', count( $pending ) ) ) . '</p>';

    foreach ( $pending as $id ) {
        $text = get_post_meta( $id, '_wc_bd_pending', true );
        echo '<div style="border:1px solid #ccd0d4;padding:12px;margin:0 0 12px">';
        echo '<strong>' . esc_html( get_the_title( $id ) ) . '</strong>';
        echo '<div style="margin:8px 0">' . wp_kses_post( $text ) . '</div>';
        echo '<form method="post">';
        wp_nonce_field( 'wc_bd_review', 'wc_bd_nonce' );
        echo '<input type="hidden" name="wc_bd_product" value="' . esc_attr( (string) $id ) . '" />';
        submit_button( 'Approve', 'primary', 'wc_bd_action', false, [ 'value' => 'approve' ] );
        echo ' ';
        submit_button( 'Discard', 'secondary', 'wc_bd_action', false, [ 'value' => 'discard' ] );
        echo '</form></div>';
    }
    echo '</div>';
}

The preview uses wp_kses_post() on output because the staged meta is stored already-sanitised — belt and braces. The nonce action is named wc_bd_review rather than the product ID, so one nonce value works across the whole list and you are not regenerating form markup per row.

Troubleshooting

  • Batch stuck on validating for more than an hour. Almost always a malformed JSONL line — one bad row invalidates the whole file. Re-run --dry-run and check the last line ends with a single newline and no trailing comma.
  • Batch fails with input_file_mismatch. You passed the wrong endpoint string. It must be exactly /v1/chat/completions, including the leading slash.
  • Ingest stages zero rows. Inspect the raw output body. Errors are inside response.body.error, not choices — a single product with a 400-character title can trip a model refusal and you need to see it.
  • WSOD on the review screen. A function name collision with another snippet. The code above defines global functions; wrap everything in a function_exists() check, or move the render callback to a class method.
  • Products re-queued after approval. The _wc_bd_batch_id meta is only written on ingest. If you discard before poll runs, the product returns to the pool — intentional, but worth documenting for editors.
  • 401 from the files endpoint. A project key without files scope. Create a new key in the project settings rather than the legacy organisation-wide key.

Next steps

  • Add a wc-batch cancel <batch_id> subcommand hitting POST /v1/batches/{id}/cancel so a badly-worded prompt can be stopped without waiting 24 hours.
  • Extend the prompt builder to read passed-in --category= filters, then re-run per category with a different tone instruction.
  • Log token usage from response.body.usage.total_tokens into a custom table, so you can attribute cost per product edit.
  • Wire the approve step into a WordPress revision note via wp_update_post()‘s post_excerpt — an audit trail editors can see in the classic editor.
  • Read the WP-CLI command handbook and nonce documentation before you extend the admin screen.

Share this:

  • Share on X (Opens in new window) X
  • Share on Facebook (Opens in new window) Facebook
  • Share on LinkedIn (Opens in new window) LinkedIn
  • Share on WhatsApp (Opens in new window) WhatsApp
  • Share on Reddit (Opens in new window) Reddit
  • Email a link to a friend (Opens in new window) Email

Related

Previous Article Expose WordPress to an AI Agent Safely: Read-Only Abilities with the Abilities API and Permission Callbacks

About the author

Md. Zubaer Ahammed
Md. Zubaer Ahammed

Md. Zubaer Ahammed is a senior full-stack developer building learning platforms and modern web systems with WordPress, Laravel, Python/Django, Vue, and React. More at https://zubaer.com/.

Related Posts

  • Expose WordPress to an AI Agent Safely: Read-Only Abilities with the Abilities API and Permission Callbacks Expose WordPress to an AI Agent Safely: Read-Only Abilities with the Abilities API and Permission Callbacks
  • Build a Provider-Agnostic AI Client for WordPress: Anthropic, OpenAI and Gemini Behind One wp_remote_post Wrapper Build a Provider-Agnostic AI Client for WordPress: Anthropic, OpenAI and Gemini Behind One wp_remote_post Wrapper
WP Tuts Hub

WordPress tutorials, themes, and plugins — written for builders.

  • Portfolio
  • Calorie Calculator Pro
  • Contact
  • About
  • Privacy
  • Terms

© 2026 WP Tuts Hub · Md. Zubaer Ahammed