Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## 3.8.2 — 2026-06-12

Codex review fixes:

- Seeding: featured-image failures raise (were sys.exit) so one bad image no longer aborts the whole batch; ACF/JetEngine field writes and featured media now route through the CPT's rest_base (were silently no-op'ing on custom post types).
- Taxonomy rest_base resolved via /wp/v2/taxonomies (was the post-type endpoint).
- site_audit: HTTP 4xx/5xx pages are audited (status/headers/SEO) instead of reported unreachable; truly unreachable targets exit non-zero.
- Machine-readable stdout: the publish-confirm prompt no longer writes to stdout (stderr only).
- Permissions disclosure notes plaintext-HTTP egress is permitted (warn-only) unless WP_REQUIRE_HTTPS=1.

## 3.8.1 — 2026-06-10

Soft security guards (ClawHub audit follow-up, non-breaking):
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
![OpenClaw Skill](https://img.shields.io/badge/OpenClaw-Skill-purple)
![WordPress](https://img.shields.io/badge/WordPress-REST_API-21759b)
![License: MIT--0](https://img.shields.io/badge/License-MIT--0-green)
![Version](https://img.shields.io/badge/version-3.8.1-blue)
![Version](https://img.shields.io/badge/version-3.8.2-blue)

A production-grade **Claude Code & OpenClaw skill** for managing WordPress content via the REST API — posts, pages, media, WooCommerce, Elementor, SEO meta, ACF, JetEngine — with explicit safety boundaries for agentic use.

Expand Down Expand Up @@ -40,7 +40,7 @@ ClawHub package directory: `wordpress-api-pro/`.

## Version

Current version: **3.5.1**
Current version: **3.8.2**

## Installation

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "wordpress-api-pro",
"version": "3.8.1",
"version": "3.8.2",
"description": "WordPress REST API integration skill for OpenClaw - manage posts, pages, media, WooCommerce, Elementor, and metadata with explicit safety boundaries",
"private": true,
"main": "wordpress-api-pro/SKILL.md",
Expand Down
81 changes: 81 additions & 0 deletions tests/test_cpt_seeding.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
sys.path.insert(0, os.path.abspath(SCRIPTS))

import create_post # noqa: E402
import upload_media # noqa: E402


class FakeResp:
Expand All @@ -30,6 +31,28 @@ def test_falls_back_to_slug_on_error(self):
create_post.resolve_rest_base("http://x", "a", "team"), "team")


class ResolveTaxonomyRestBaseTest(unittest.TestCase):
def test_hits_taxonomies_endpoint_not_types(self):
"""A taxonomy's rest_base must be read from /wp/v2/taxonomies/{tax},
NOT the post-type /wp/v2/types/{...} endpoint (which 404s for a tax)."""
seen = {}

def fake_get(url, auth):
seen["url"] = url
return {"rest_base": "project_category"}

with mock.patch.object(create_post, "_get", side_effect=fake_get):
rb = create_post.resolve_taxonomy_rest_base("http://x", "a", "project_cat")
self.assertEqual(rb, "project_category")
self.assertIn("/wp-json/wp/v2/taxonomies/project_cat", seen["url"])
self.assertNotIn("/types/", seen["url"])

def test_falls_back_to_slug_on_error(self):
with mock.patch.object(create_post, "_get", side_effect=Exception("404")):
self.assertEqual(
create_post.resolve_taxonomy_rest_base("http://x", "a", "genre"), "genre")


class ResolveTermsTest(unittest.TestCase):
def test_existing_term_resolves_to_id(self):
responses = [
Expand All @@ -43,6 +66,64 @@ def test_existing_term_resolves_to_id(self):
create_missing=False)
self.assertEqual(out, {"project_category": [5]})

def test_term_resolution_queries_taxonomy_rest_base(self):
"""The term search + create must use the taxonomy's rest_base in the URL,
AND the result must be keyed by that rest_base (`genre`), not the taxonomy
slug (`music_genre`) — the post endpoint expects term ids under the
rest_base field, so a renamed base must survive end-to-end."""
seen = []

def fake_urlopen(req, *a, **k):
seen.append(req.full_url)
if "/taxonomies/" in req.full_url:
return FakeResp({"rest_base": "genre"})
return FakeResp([{"id": 9, "name": "Jazz"}])

with mock.patch.object(create_post.urllib.request, "urlopen",
side_effect=fake_urlopen):
out = create_post.resolve_terms("http://x", "a",
{"music_genre": ["Jazz"]},
create_missing=False)
self.assertEqual(out, {"genre": [9]}) # keyed by rest_base, not slug
self.assertNotIn("music_genre", out)
self.assertTrue(any("/wp-json/wp/v2/genre?search=" in u for u in seen))


class SetFeaturedImageTest(unittest.TestCase):
def test_failure_raises_recoverable_exception_not_systemexit(self):
"""A featured-image failure must raise a normal Exception so a batching
caller (seed_content.seed) can record a per-entry failure — never
SystemExit, which its `except Exception` would not catch."""
with mock.patch.object(upload_media.urllib.request, "urlopen",
side_effect=Exception("boom")):
try:
upload_media.set_featured_image("http://x", "u", "p", 1, 2)
except SystemExit:
self.fail("set_featured_image raised SystemExit — would abort the whole seed batch")
except Exception:
pass # expected: a recoverable exception
else:
self.fail("set_featured_image did not raise on failure")

def test_failure_raises_runtimeerror(self):
with mock.patch.object(upload_media.urllib.request, "urlopen",
side_effect=Exception("boom")):
with self.assertRaises(RuntimeError):
upload_media.set_featured_image("http://x", "u", "p", 1, 2)

def test_routes_through_rest_base(self):
"""CPT entries must set featured media on /wp/v2/{rest_base}/{id}."""
seen = {}

def fake_urlopen(req, *a, **k):
seen["url"] = req.full_url
return FakeResp({"id": 7, "featured_media": 2})

with mock.patch.object(upload_media.urllib.request, "urlopen",
side_effect=fake_urlopen):
upload_media.set_featured_image("http://x", "u", "p", 7, 2, rest_base="projects")
self.assertIn("/wp-json/wp/v2/projects/7", seen["url"])


import seed_content # noqa: E402

Expand Down
28 changes: 27 additions & 1 deletion tests/test_site_audit.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os, sys, unittest
import io, os, sys, unittest, urllib.error
from unittest import mock

SCRIPTS = os.path.join(os.path.dirname(__file__), "..", "wordpress-api-pro", "scripts")
sys.path.insert(0, os.path.abspath(SCRIPTS))
Expand Down Expand Up @@ -70,5 +71,30 @@ def test_grade(self):
self.assertEqual(sa.grade_pagespeed(0.50), "fail")


class GetHttpErrorTest(unittest.TestCase):
def test_http_error_returns_structured_result(self):
"""A 4xx/5xx must return (code, headers, url, body) so status/header/SEO
checks still run — an HTTP error page is a *reachable* server, not
'unreachable'."""
err = urllib.error.HTTPError(
url="http://example.com/x", code=403, msg="Forbidden",
hdrs={"Content-Type": "text/html", "Server": "nginx"},
fp=io.BytesIO(b"<html>denied</html>"))
with mock.patch.object(sa.urllib.request, "urlopen", side_effect=err):
code, headers, final_url, body = sa._get("http://example.com/x")
self.assertEqual(code, 403)
self.assertEqual(final_url, "http://example.com/x")
self.assertEqual(body, "<html>denied</html>")
self.assertIn("Content-Type", headers)

def test_connection_error_still_propagates(self):
"""DNS/timeout/refused (URLError) must still propagate → audit() marks
the site unreachable."""
with mock.patch.object(sa.urllib.request, "urlopen",
side_effect=urllib.error.URLError("name resolution failed")):
with self.assertRaises(urllib.error.URLError):
sa._get("http://nonexistent.invalid")


if __name__ == "__main__":
unittest.main()
4 changes: 2 additions & 2 deletions wordpress-api-pro/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: wordpress-api-pro
version: 3.8.1
version: 3.8.2
license: MIT-0
description: |
Production-grade WordPress REST API integration for managing posts, pages, media, WooCommerce products, Elementor content, SEO meta, ACF, and JetEngine fields.
Expand All @@ -14,7 +14,7 @@ permissions:
- "WP_CONFIG (optional sites.json path), WP_ALLOWED_FILE_ROOTS (file-read scope)"
- "WP_ALLOW_REMOTE_URLS, WP_REQUIRE_HTTPS, WP_REQUIRE_ALLOWLIST, PAGESPEED_API_KEY"
network:
- "Outbound HTTPS to the configured WordPress site(s) /wp-json/ REST API"
- "Outbound HTTP/HTTPS to the configured WordPress site(s) /wp-json/ REST API — plaintext http:// is permitted (warn-only) unless WP_REQUIRE_HTTPS=1"
- "https://www.googleapis.com/pagespeedonline (site_audit only)"
filesystem:
- "Read-only, scoped to WP_ALLOWED_FILE_ROOTS (default: cwd)"
Expand Down
22 changes: 14 additions & 8 deletions wordpress-api-pro/scripts/acf_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,32 +76,38 @@ def get_acf_fields(url, username, password, post_id, field_name=None):
except requests.exceptions.RequestException as e:
return {"error": str(e)}

def set_acf_fields(url, username, password, post_id, fields_dict):
"""Set ACF fields via REST API (with postmeta fallback)"""

def set_acf_fields(url, username, password, post_id, fields_dict, rest_base="posts"):
"""Set ACF fields via REST API (with postmeta fallback).

`rest_base` is the REST base of the target post type (default "posts"). For a
custom post type pass its rest_base so writes hit /acf/v3/{rest_base}/{id} and
the /wp/v2/{rest_base}/{id} fallback — writing to /posts/ silently no-ops on
a CPT.
"""

credentials = f"{username}:{password}"
auth_header = 'Basic ' + b64encode(credentials.encode()).decode()
headers = {
'Authorization': auth_header,
'Content-Type': 'application/json'
}

base_url = url.rstrip('/')

# Try ACF REST endpoint first
try:
payload = {'fields': fields_dict}
response = requests.post(f"{base_url}/wp-json/acf/v3/posts/{post_id}",
response = requests.post(f"{base_url}/wp-json/acf/v3/{rest_base}/{post_id}",
headers=headers, json=payload, timeout=10)
if response.status_code in [200, 201]:
return response.json()
except:
pass

# Fallback to postmeta
try:
payload = {'meta': fields_dict}
response = requests.post(f"{base_url}/wp-json/wp/v2/posts/{post_id}",
response = requests.post(f"{base_url}/wp-json/wp/v2/{rest_base}/{post_id}",
headers=headers, json=payload, timeout=10)
if response.status_code in [200, 201]:
return response.json()
Expand Down
36 changes: 28 additions & 8 deletions wordpress-api-pro/scripts/create_post.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,35 @@ def resolve_rest_base(base_url, auth, post_type):
return post_type


def resolve_taxonomy_rest_base(base_url, auth, taxonomy):
"""Resolve a taxonomy's REST base; fall back to the slug on any error.

Taxonomies live under /wp/v2/taxonomies/{taxonomy}, NOT /wp/v2/types/{...}
(that's the post-type endpoint and 404s for a taxonomy). Using the wrong
endpoint would silently fall back to the slug and break any taxonomy with a
renamed rest_base.
"""
try:
info = _get(f"{base_url.rstrip('/')}/wp-json/wp/v2/taxonomies/{taxonomy}", auth)
return info.get('rest_base') or taxonomy
except Exception:
return taxonomy


def resolve_terms(base_url, auth, terms_dict, create_missing=True):
"""Map {taxonomy: [name|id, ...]} -> {taxonomy: [id, ...]}.
"""Map {taxonomy: [name|id, ...]} -> {rest_base: [id, ...]}.

Names are resolved (and optionally created) via the taxonomy's REST base.
Integer-like values pass through as ids.
Integer-like values pass through as ids. The returned dict is keyed by the
taxonomy's REST base (e.g. `genres`, `categories`), NOT the taxonomy slug —
that is the field the post endpoint expects term ids under, so a taxonomy
with a custom rest_base (or the built-in category/post_tag whose bases are
categories/tags) attaches correctly.
"""
base_url = base_url.rstrip('/')
out = {}
for taxonomy, values in (terms_dict or {}).items():
tax_base = resolve_rest_base(base_url, auth, taxonomy) # taxonomy rest_base
tax_base = resolve_taxonomy_rest_base(base_url, auth, taxonomy) # taxonomy rest_base

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use taxonomy REST base when sending term IDs

When a taxonomy has a custom rest_base, this now correctly searches/creates terms through that base, but resolve_terms() still returns IDs keyed by the original taxonomy slug, so create_post() posts {'genre': [id]} instead of the REST field WordPress exposes, e.g. {'genres': [id]}. In that scenario the post can be created without the requested terms even though term resolution succeeded; return/store the IDs under tax_base (or otherwise preserve both route and payload key correctly) to make renamed taxonomy bases work end-to-end.

Useful? React with 👍 / 👎.

ids = []
for v in values:
if isinstance(v, int) or (isinstance(v, str) and v.isdigit()):
Expand All @@ -57,7 +76,7 @@ def resolve_terms(base_url, auth, terms_dict, create_missing=True):
ids.append(created['id'])
else:
raise ValueError(f"Term '{v}' not found in '{taxonomy}'")
out[taxonomy] = ids
out[tax_base] = ids # key by REST base, not slug — that's the post field
return out


Expand All @@ -72,9 +91,9 @@ def create_post(url, username, password, title, content, status='draft',
if featured_media:
data['featured_media'] = int(featured_media)
if terms:
resolved = resolve_terms(base, auth, terms)
for taxonomy, ids in resolved.items():
data[taxonomy] = ids # REST accepts the taxonomy key with term ids
resolved = resolve_terms(base, auth, terms) # keyed by REST base
for tax_base, ids in resolved.items():
data[tax_base] = ids # post endpoint expects term ids under the rest_base

return _post(f"{base}/wp-json/wp/v2/{rest_base}", auth, data)

Expand All @@ -98,7 +117,8 @@ def main():
warn_insecure_wp_url(a.url)
if should_confirm_publish(a.status, a.yes, sys.stdin.isatty()):
print("About to PUBLISH live content to %s. Type 'PUBLISH' to confirm:" % a.url, file=sys.stderr)
if input("> ").strip() != "PUBLISH":
print("> ", end="", file=sys.stderr)
if input().strip() != "PUBLISH":
print("Aborted: publish not confirmed.", file=sys.stderr)
sys.exit(1)
try:
Expand Down
17 changes: 11 additions & 6 deletions wordpress-api-pro/scripts/jetengine_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,21 +65,26 @@ def get_jetengine_fields(url, username, password, post_id, field_name=None):
except requests.exceptions.RequestException as e:
return {"error": str(e)}

def set_jetengine_fields(url, username, password, post_id, fields_dict):
"""Set JetEngine fields (via postmeta)"""

def set_jetengine_fields(url, username, password, post_id, fields_dict, rest_base="posts"):
"""Set JetEngine fields (via postmeta).

`rest_base` is the REST base of the target post type (default "posts"). For a
custom post type pass its rest_base so the write hits /wp/v2/{rest_base}/{id};
writing to /posts/ silently no-ops on a CPT.
"""

credentials = f"{username}:{password}"
auth_header = 'Basic ' + b64encode(credentials.encode()).decode()
headers = {
'Authorization': auth_header,
'Content-Type': 'application/json'
}

base_url = url.rstrip('/')

try:
payload = {'meta': fields_dict}
response = requests.post(f"{base_url}/wp-json/wp/v2/posts/{post_id}",
response = requests.post(f"{base_url}/wp-json/wp/v2/{rest_base}/{post_id}",
headers=headers, json=payload, timeout=10)
if response.status_code in [200, 201]:
return response.json()
Expand Down
Loading
Loading