From 2e4d2b23d5084c729319b3583e20656a55f5753f Mon Sep 17 00:00:00 2001 From: Ben Kalsky Date: Fri, 3 Jul 2026 12:44:35 +0300 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20address=20Codex=20review=20findings?= =?UTF-8?q?=20=E2=80=94=20CPT=20seeding=20routes,=20site=5Faudit=20reachab?= =?UTF-8?q?ility,=20stdout=20hygiene=20(v3.8.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 10 +++ README.md | 4 +- package.json | 2 +- tests/test_cpt_seeding.py | 77 +++++++++++++++++++ tests/test_site_audit.py | 28 ++++++- wordpress-api-pro/SKILL.md | 4 +- wordpress-api-pro/scripts/acf_fields.py | 22 ++++-- wordpress-api-pro/scripts/create_post.py | 20 ++++- wordpress-api-pro/scripts/jetengine_fields.py | 17 ++-- wordpress-api-pro/scripts/seed_content.py | 35 ++++++++- wordpress-api-pro/scripts/site_audit.py | 21 +++-- wordpress-api-pro/scripts/update_post.py | 3 +- wordpress-api-pro/scripts/upload_media.py | 56 ++++++++------ 13 files changed, 244 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35416d1..388fe1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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): diff --git a/README.md b/README.md index 9080ad7..a4fb831 100644 --- a/README.md +++ b/README.md @@ -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. @@ -40,7 +40,7 @@ ClawHub package directory: `wordpress-api-pro/`. ## Version -Current version: **3.5.1** +Current version: **3.8.2** ## Installation diff --git a/package.json b/package.json index bf23573..48df9f1 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/tests/test_cpt_seeding.py b/tests/test_cpt_seeding.py index 8455d92..5bde6c2 100644 --- a/tests/test_cpt_seeding.py +++ b/tests/test_cpt_seeding.py @@ -5,6 +5,7 @@ sys.path.insert(0, os.path.abspath(SCRIPTS)) import create_post # noqa: E402 +import upload_media # noqa: E402 class FakeResp: @@ -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 = [ @@ -43,6 +66,60 @@ 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.""" + 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, {"music_genre": [9]}) + 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 diff --git a/tests/test_site_audit.py b/tests/test_site_audit.py index 24da109..98c3a9f 100644 --- a/tests/test_site_audit.py +++ b/tests/test_site_audit.py @@ -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)) @@ -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"denied")) + 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, "denied") + 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() diff --git a/wordpress-api-pro/SKILL.md b/wordpress-api-pro/SKILL.md index 722dd38..ff184c4 100644 --- a/wordpress-api-pro/SKILL.md +++ b/wordpress-api-pro/SKILL.md @@ -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. @@ -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)" diff --git a/wordpress-api-pro/scripts/acf_fields.py b/wordpress-api-pro/scripts/acf_fields.py index 24388f0..fd0eef7 100755 --- a/wordpress-api-pro/scripts/acf_fields.py +++ b/wordpress-api-pro/scripts/acf_fields.py @@ -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() diff --git a/wordpress-api-pro/scripts/create_post.py b/wordpress-api-pro/scripts/create_post.py index 387c080..8d2389d 100755 --- a/wordpress-api-pro/scripts/create_post.py +++ b/wordpress-api-pro/scripts/create_post.py @@ -33,6 +33,21 @@ 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, ...]}. @@ -42,7 +57,7 @@ def resolve_terms(base_url, auth, terms_dict, create_missing=True): 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 ids = [] for v in values: if isinstance(v, int) or (isinstance(v, str) and v.isdigit()): @@ -98,7 +113,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: diff --git a/wordpress-api-pro/scripts/jetengine_fields.py b/wordpress-api-pro/scripts/jetengine_fields.py index ccebb5e..502f3b2 100755 --- a/wordpress-api-pro/scripts/jetengine_fields.py +++ b/wordpress-api-pro/scripts/jetengine_fields.py @@ -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() diff --git a/wordpress-api-pro/scripts/seed_content.py b/wordpress-api-pro/scripts/seed_content.py index 354dac3..8256a09 100644 --- a/wordpress-api-pro/scripts/seed_content.py +++ b/wordpress-api-pro/scripts/seed_content.py @@ -50,6 +50,14 @@ def _resolve_image(url, user, pw, fi, allow_remote): return res.get('id') if isinstance(res, dict) else None +def _field_error(result): + """Return an error string if an acf/jet write returned an error dict, else None.""" + if isinstance(result, dict) and result.get('error'): + details = result.get('details') + return f"{result['error']}{(' ' + json.dumps(details)) if details else ''}" + return None + + def seed(url, user, pw, dataset, allow_remote=False): """Execute the seed. Returns {created: [...], failed: [...]}.""" import create_post as _cp @@ -57,21 +65,40 @@ def seed(url, user, pw, dataset, allow_remote=False): import jetengine_fields as _jet import upload_media as _media created, failed = [], [] + base = url.rstrip('/') + auth = _cp._auth(user, pw) + rest_base_cache = {} + + def _rest_base(post_type): + # Resolve (and cache) the post type's REST base so ACF/Jet/featured-media + # writes hit the CPT's own route instead of the post-only /posts/ route. + if post_type not in rest_base_cache: + rest_base_cache[post_type] = _cp.resolve_rest_base(base, auth, post_type) + return rest_base_cache[post_type] + for e in dataset: try: + post_type = e.get('post_type', 'post') post = _cp.create_post( url, user, pw, e['title'], e.get('content', ''), - e.get('status', 'draft'), post_type=e.get('post_type', 'post'), + e.get('status', 'draft'), post_type=post_type, terms=e.get('terms')) pid = post['id'] + rest_base = _rest_base(post_type) if e.get('acf'): - _acf.set_acf_fields(url, user, pw, pid, e['acf']) + res = _acf.set_acf_fields(url, user, pw, pid, e['acf'], rest_base=rest_base) + err = _field_error(res) + if err: + raise RuntimeError(f"ACF write failed: {err}") if e.get('jet'): - _jet.set_jetengine_fields(url, user, pw, pid, e['jet']) + res = _jet.set_jetengine_fields(url, user, pw, pid, e['jet'], rest_base=rest_base) + err = _field_error(res) + if err: + raise RuntimeError(f"JetEngine write failed: {err}") if e.get('featured_image') is not None: mid = _resolve_image(url, user, pw, e['featured_image'], allow_remote) if mid: - _media.set_featured_image(url, user, pw, pid, mid) + _media.set_featured_image(url, user, pw, pid, mid, rest_base=rest_base) created.append({'id': pid, 'title': e['title']}) except Exception as ex: failed.append({'title': e.get('title', '(no title)'), 'error': str(ex)}) diff --git a/wordpress-api-pro/scripts/site_audit.py b/wordpress-api-pro/scripts/site_audit.py index 95d455c..41fc7e9 100644 --- a/wordpress-api-pro/scripts/site_audit.py +++ b/wordpress-api-pro/scripts/site_audit.py @@ -9,7 +9,7 @@ python3 site_audit.py https://example.com --summary Env (optional): PAGESPEED_API_KEY (higher PageSpeed Insights quota) """ -import argparse, json, os, re, ssl, socket, sys, urllib.request, urllib.parse +import argparse, json, os, re, ssl, socket, sys, urllib.request, urllib.parse, urllib.error from datetime import datetime, timezone UA = "Mozilla/5.0 (compatible; DigitizerAudit/1.0)" @@ -84,9 +84,17 @@ def grade_pagespeed(score): # ---- fetching (network; not unit-tested) ----------------------------------- def _get(url, method="GET", timeout=15): req = urllib.request.Request(url, method=method, headers={"User-Agent": UA}) - with urllib.request.urlopen(req, timeout=timeout) as r: - body = r.read().decode("utf-8", "replace") if method == "GET" else "" - return r.getcode(), dict(r.headers), r.geturl(), body + try: + with urllib.request.urlopen(req, timeout=timeout) as r: + body = r.read().decode("utf-8", "replace") if method == "GET" else "" + return r.getcode(), dict(r.headers), r.geturl(), body + except urllib.error.HTTPError as e: + # An HTTP 4xx/5xx is a *reachable* server — return its status, headers, + # url and body so the status/header/SEO checks still run. Only DNS / + # timeout / connection-refused (URLError, socket errors) mean unreachable, + # and those propagate to the caller's generic except. + body = e.read().decode("utf-8", "replace") if method == "GET" else "" + return e.code, dict(e.headers), e.geturl(), body def _ssl_notafter(host, port=443, timeout=10): @@ -200,7 +208,10 @@ def main(): url = "https://" + url result = audit(url, api_key=os.getenv("PAGESPEED_API_KEY")) print(_summary(result) if a.summary else json.dumps(result, indent=2)) - if result["reachable"] and any(f["status"] == "fail" for f in result["findings"]): + # Exit non-zero for a truly unreachable target (DNS/timeout/refused) OR any + # failing check — otherwise CI/automation would read an unreachable site as + # a false success. + if (not result["reachable"]) or any(f["status"] == "fail" for f in result["findings"]): sys.exit(2) diff --git a/wordpress-api-pro/scripts/update_post.py b/wordpress-api-pro/scripts/update_post.py index 726a045..0d7817b 100755 --- a/wordpress-api-pro/scripts/update_post.py +++ b/wordpress-api-pro/scripts/update_post.py @@ -96,7 +96,8 @@ def main(): warn_insecure_wp_url(args.url) if should_confirm_publish(args.status, args.yes, sys.stdin.isatty()): print("About to PUBLISH live content to %s. Type 'PUBLISH' to confirm:" % args.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) if not args.username: diff --git a/wordpress-api-pro/scripts/upload_media.py b/wordpress-api-pro/scripts/upload_media.py index 62d51b0..17caf06 100755 --- a/wordpress-api-pro/scripts/upload_media.py +++ b/wordpress-api-pro/scripts/upload_media.py @@ -26,8 +26,7 @@ def upload_media(url, username, app_credential, file_path, title=None, alt_text= except SafetyError: raise except Exception as e: - print(json.dumps({"error": f"Failed to download file: {str(e)}"}), file=sys.stderr) - sys.exit(1) + raise RuntimeError(f"Failed to download file: {str(e)}") else: # Read local file after validating it is inside an allowed root. try: @@ -43,8 +42,7 @@ def upload_media(url, username, app_credential, file_path, title=None, alt_text= with open(safe_path, 'rb') as f: file_data = f.read() except Exception as e: - print(json.dumps({"error": f"Failed to read file: {str(e)}"}), file=sys.stderr) - sys.exit(1) + raise RuntimeError(f"Failed to read file: {str(e)}") # Prepare multipart form data manually boundary = '----WebKitFormBoundary' + ''.join([str(x) for x in os.urandom(16)]) @@ -92,31 +90,40 @@ def upload_media(url, username, app_credential, file_path, title=None, alt_text= return result except urllib.error.HTTPError as e: error_body = e.read().decode('utf-8') - print(json.dumps({"error": f"HTTP {e.code}: {error_body}"}), file=sys.stderr) - sys.exit(1) - except Exception as e: - print(json.dumps({"error": str(e)}), file=sys.stderr) - sys.exit(1) + raise RuntimeError(f"HTTP {e.code}: {error_body}") + except Exception: + # Re-raise so importable callers (e.g. seed_content) can record a + # per-entry failure via their own `except Exception`. The CLI main() + # wraps this and exits non-zero. + raise + +def set_featured_image(url, username, app_credential, post_id, media_id, rest_base="posts"): + """Set featured image for a post/CPT entry. -def set_featured_image(url, username, app_credential, post_id, media_id): - """Set featured image for a post""" - api_url = f"{url.rstrip('/')}/wp-json/wp/v2/posts/{post_id}" + Raises RuntimeError on failure (does NOT sys.exit) so a caller batching + many entries — e.g. seed_content.seed() — can catch it per entry instead of + aborting the whole run. `rest_base` routes CPT entries to their own REST + base (defaults to the built-in "posts"). + """ + api_url = f"{url.rstrip('/')}/wp-json/wp/v2/{rest_base}/{post_id}" credentials = f"{username}:{app_credential}".encode('utf-8') auth_header = b64encode(credentials).decode('ascii') - + data = {'featured_media': media_id} - + request = urllib.request.Request(api_url, data=json.dumps(data).encode('utf-8'), method='POST') request.add_header('Authorization', f'Basic {auth_header}') request.add_header('Content-Type', 'application/json') - + try: with urllib.request.urlopen(request) as response: result = json.loads(response.read().decode('utf-8')) return result + except urllib.error.HTTPError as e: + error_body = e.read().decode('utf-8') + raise RuntimeError(f"Failed to set featured image: HTTP {e.code}: {error_body}") except Exception as e: - print(json.dumps({"error": str(e)}), file=sys.stderr) - sys.exit(1) + raise RuntimeError(f"Failed to set featured image: {e}") def main(): @@ -143,7 +150,8 @@ def main(): print(json.dumps({"error": "--post-id required when using --set-featured"}), file=sys.stderr) sys.exit(1) - # Upload media + # Upload media. The importable helpers now raise on failure; the CLI + # translates that back into a non-zero exit with a JSON error on stderr. try: result = upload_media( args.url, @@ -155,13 +163,15 @@ def main(): caption=args.caption, allow_remote_url=args.allow_remote_url, ) + # Set as featured image if requested + if args.set_featured and 'id' in result: + set_featured_image(args.url, args.username, args.app_password, args.post_id, result['id']) + result['featured_image_set'] = True except SafetyError as e: die_safety(e) - - # Set as featured image if requested - if args.set_featured and 'id' in result: - set_featured_image(args.url, args.username, args.app_password, args.post_id, result['id']) - result['featured_image_set'] = True + except Exception as e: + print(json.dumps({"error": str(e)}), file=sys.stderr) + sys.exit(1) print(json.dumps(result, indent=2)) From df082af2ad7406e346e970a1ab02f48537a1e1ff Mon Sep 17 00:00:00 2001 From: Ben Kalsky Date: Fri, 3 Jul 2026 12:56:28 +0300 Subject: [PATCH 2/2] fix: key resolved term ids by taxonomy rest_base, not slug (Codex follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolve_terms returned ids under the taxonomy slug; create_post posts under the rest_base field, so a taxonomy with a custom rest_base (or built-in category/post_tag → categories/tags) attached no terms even though resolution succeeded. Co-Authored-By: Claude Opus 4.8 --- tests/test_cpt_seeding.py | 8 ++++++-- wordpress-api-pro/scripts/create_post.py | 16 ++++++++++------ 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/tests/test_cpt_seeding.py b/tests/test_cpt_seeding.py index 5bde6c2..0be826e 100644 --- a/tests/test_cpt_seeding.py +++ b/tests/test_cpt_seeding.py @@ -67,7 +67,10 @@ def test_existing_term_resolves_to_id(self): 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.""" + """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): @@ -81,7 +84,8 @@ def fake_urlopen(req, *a, **k): out = create_post.resolve_terms("http://x", "a", {"music_genre": ["Jazz"]}, create_missing=False) - self.assertEqual(out, {"music_genre": [9]}) + 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)) diff --git a/wordpress-api-pro/scripts/create_post.py b/wordpress-api-pro/scripts/create_post.py index 8d2389d..8f1135e 100755 --- a/wordpress-api-pro/scripts/create_post.py +++ b/wordpress-api-pro/scripts/create_post.py @@ -49,10 +49,14 @@ def resolve_taxonomy_rest_base(base_url, auth, 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 = {} @@ -72,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 @@ -87,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)