Bulk publishing to WordPress is not difficult because of the POST request. It is difficult because a production batch must survive retries, interrupted runs, malformed AI output, duplicate slugs, taxonomy lookups, media failures, and secret handling. This guide builds a maintainable Perl project that handles those edges and creates drafts through the WordPress REST API.
The runner accepts two topics or two hundred without changing the code. It generates one article at a time, validates the result, checks WordPress for an existing slug, resolves categories and tags, optionally uploads a featured image, and then creates the post. A failure is recorded and the next topic continues.
draft. Direct publishing is available through an explicit option, but only after a dry run and a small draft batch have been reviewed.Project layout
wordpress_bulk_posts/
├── data/
│ └── topics.json
├── lib/
│ ├── ContentGenerator.pm
│ ├── Slug.pm
│ └── WordPressClient.pm
├── logs/
│ └── batch.log
├── .env.example
├── README.md
└── wordpress_bulk_posts.pl
The responsibilities stay deliberately narrow. WordPressClient.pm owns HTTP, authentication, retry policy, terms, media, and posts. ContentGenerator.pm owns the AI request and JSON repair attempts. Slug.pm owns transliteration. The executable owns iteration, validation, logging, counters, and command-line options.
Install the Perl dependencies
On Ubuntu or Debian, install the HTTP and URI packages from apt. JSON::PP ships with modern Perl, while Text::Unidecode is optional because the project includes a Vietnamese fallback.
sudo apt update
sudo apt install perl cpanminus libwww-perl liburi-perl
libtext-unidecode-perl
# Optional faster JSON implementation
sudo cpanm JSON::MaybeXS
Do not disable TLS verification to make an HTTPS error disappear. Fix the certificate chain, site URL, DNS, or proxy configuration instead.
Create a WordPress Application Password
Open Users → Profile → Application Passwords in WordPress and create a dedicated credential for automation. Use a separate WordPress user with only the capabilities required to create posts, terms, and media.
export WP_URL='https://example.com'
export WP_USERNAME='automation-user'
export WP_APP_PASSWORD='xxxx xxxx xxxx xxxx'
export AI_API_KEY='...'
export AI_MODEL='gpt-5-mini'
Credentials never belong in topics.json, source code, command output, or the batch log. Basic authentication is acceptable here because the entire request is protected by HTTPS; the Application Password can also be revoked independently.
Define topics as data
The input is a JSON array. The runner does not hard-code a batch size, so the same structure works for 3, 30, or 300 topics.
[
{
"keyword": "tu dong hoa WordPress bang Perl",
"title": "Tu dong hoa dang bai WordPress bang Perl va REST API",
"category": "Development",
"tags": ["Perl", "WordPress REST API", "Automation"],
"intent": "informational",
"featured_image": "https://cdn.example.com/perl-wordpress.jpg"
}
]
featured_image is optional. A failed download or media upload is logged, but it does not abort the article or the remainder of the batch.
Generate structured content
The generator asks the model for one JSON object, not free-form Markdown. This makes the boundary between generation and publishing explicit:
{
"title": "...",
"slug": "...",
"excerpt": "...",
"meta_description": "...",
"content": "<p>...</p><h2>...</h2>"
}
The prompt reserves H1 for the WordPress title and requires clean HTML. It asks for varied openings and section structures, useful H2 headings, optional H3 sections, lists only where natural, and FAQ items when they help. The generator retries malformed responses, strips an accidental JSON code fence, and parses the result again.
sub generate {
my ($self, $topic) = @_;
my $prompt = $instructions . "nTopic JSON:n" . encode_json($topic);
for my $attempt (1 .. 3) {
my $response = $self->request_ai($prompt);
my $text = extract_output_text($response);
$text =~ s/^s*```(?:json)?s*|s*```s*$//g;
my $article = eval { decode_json($text) };
return $article if $article && ref $article eq 'HASH';
sleep(2 ** ($attempt - 1));
}
die "AI response was not valid JSON after retriesn";
}
Keep the model name configurable. Model availability changes, and production code should not require a source edit just to move between approved models.
Validate before writing
AI output is untrusted input. Validation runs before any taxonomy or post write:
sub validate_article {
my ($article) = @_;
die "title is emptyn" unless $article->{title} =~ /S/;
die "slug is invalidn"
unless $article->{slug} =~ /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
die "content is too shortn"
unless length($article->{content} // '') >= 3000;
die "Markdown fence foundn" if $article->{content} =~ /```/;
die "H1 belongs to WordPressn" if $article->{content} =~ /<h1b/i;
die "placeholder foundn"
if $article->{content} =~ /(?:[INSERT[^]]*]|TODO|Lorem ipsum)/i;
die "meta description is too longn"
if length($article->{meta_description} // '') > 160;
}
These checks are intentionally cheap and deterministic. A full HTML parser can be added for stricter projects, but basic structural checks catch the most common model failures without rewriting valid Gutenberg-compatible HTML.
Prevent duplicate posts and support resume
Before a POST, query WordPress with the final slug and an edit context:
GET /wp-json/wp/v2/posts?slug=final-slug&status=any&context=edit&per_page=1
If WordPress returns a post, the runner reports SKIPPED_EXISTING. That single rule provides practical idempotency: after a network failure or interruption at item 17, run the same command again. Items 1–16 are discovered by slug and skipped.
Checking only published posts is a common bug. The query must include drafts, pending posts, private posts, and other editable states, or a resumed batch may duplicate its own drafts.
Resolve categories and tags efficiently
Category and tag helpers first search for an exact case-insensitive name. If no exact match exists, they create the term and cache its ID for the rest of the process.
sub get_or_create_term {
my ($self, $taxonomy, $name) = @_;
my $endpoint = $taxonomy eq 'category' ? 'categories' : 'tags';
my $key = "$endpoint" . lc $name;
return $self->{term_cache}{$key} if $self->{term_cache}{$key};
my $terms = $self->search_terms($endpoint, $name);
for my $term (@$terms) {
return $self->{term_cache}{$key} = $term->{id}
if lc($term->{name}) eq lc($name);
}
my $term = $self->post("/$endpoint", { name => $name });
return $self->{term_cache}{$key} = $term->{id};
}
The exact-name comparison matters because WordPress search is fuzzy. Without it, a query for Perl could incorrectly reuse a term whose name merely contains that word.
Upload an optional featured image
The media flow downloads the remote image, preserves its content type, sends the bytes to /wp-json/wp/v2/media, and assigns the returned ID as featured_media. The upload request needs a safe Content-Disposition filename and the same Application Password authentication.
my $featured_media = 0;
if ($topic->{featured_image}) {
eval {
$featured_media = $wp->upload_featured_image(
$topic->{featured_image}, $article->{title}
);
};
warn "Featured image skipped: $@" if $@;
}
For stronger production controls, also enforce a maximum response size, allowlisted MIME types, and an image-dimension limit before uploading.
Create the WordPress draft
my $post = $wp->create_post({
title => $article->{title},
slug => $article->{slug},
content => $article->{content},
excerpt => $article->{excerpt},
status => 'draft',
categories => @category_ids,
tags => @tag_ids,
featured_media => $featured_media,
});
WordPress core has no universal REST field for an SEO meta description. Yoast, Rank Math, and custom themes use different keys and exposure rules. Treat SEO metadata as an adapter: register the chosen meta key with show_in_rest, or call the site’s supported integration rather than pretending every installation accepts the same field.
Retry transient HTTP failures
Retry only failures that may reasonably recover: HTTP 429, 500, 502, 503, and transport timeouts. Authentication, authorization, validation, and not-found errors should fail immediately because waiting does not repair them.
for my $attempt (0 .. $max_retries) {
my $response = $ua->request($request);
return decode_json($response->decoded_content)
if $response->is_success;
my $retryable = $response->code == 429
|| $response->code =~ /^(?:500|502|503)$/;
die format_error($response)
unless $retryable && $attempt < $max_retries;
sleep(2 ** $attempt);
}
Never include the Authorization header, Application Password, or AI key in an exception or log message. The batch log only needs timestamp, keyword, slug, post ID, status, and a sanitized error.
Dry-run and test the first three topics
A dry run still calls the content generator and executes validation, but it creates no categories, tags, media, or posts.
cd wordpress_bulk_posts
# Generate and validate three articles, with no WordPress writes
perl wordpress_bulk_posts.pl --dry-run --limit 3
# Create three drafts after reviewing dry-run output
perl wordpress_bulk_posts.pl --limit 3
# Resume or process the complete file
perl wordpress_bulk_posts.pl
Expected terminal output remains easy to scan:
[01/30] Generating: keyword...
[01/30] Creating WordPress draft...
[01/30] CREATED post_id=123
[02/30] SKIPPED_EXISTING slug=existing-slug
Total: 30
Created: 28
Skipped: 1
Failed: 1
Operational checklist
- Use a dedicated, least-privilege WordPress account and HTTPS.
- Run
perl -con every module and the executable. - Start with
--dry-run --limit 3. - Review generated HTML, links, claims, and media rights.
- Create a small draft batch before processing the complete topic list.
- Verify duplicate detection includes non-published statuses.
- Monitor HTTP 429/5xx rates and keep retry limits bounded.
- Never print credentials or request authorization headers.
- Use direct publishing only as an explicit, reviewed operation.
Common WordPress REST API errors
- 401: verify the username, Application Password, HTTPS URL, and whether a proxy strips the Authorization header.
- 403: the user lacks permission to create posts, terms, or media.
- 404: check the site URL, permalinks, and availability of
/wp-json/wp/v2/posts. - 413: reduce image size or adjust the web server and PHP upload limits.
- 429: respect rate limits and retain exponential backoff.
- 500/502/503: retry a bounded number of times, then log the item and continue.
Conclusion
A reliable WordPress bulk publisher is a small pipeline, not a single API call. Structured generation, deterministic validation, slug-based idempotency, cached taxonomy resolution, isolated media errors, bounded retries, and draft-first operation make the difference between a useful automation tool and a cleanup job.
The safest first real command remains:
perl wordpress_bulk_posts.pl --limit 3