This blog post, “Turn WordPress Posts into “Voice Blogs” with Python + OpenAI TTS” will show you how to pull posts from a WordPress site via the REST API, converts the article content to speech using OpenAI’s Text-to-Speech (TTS), saves an MP3, and (optionally) uploads the file back to your WordPress Media Library.
Below is a guided tour of how it works, with key snippets and tips to adapt it for your site.
Table of contents
What the script does (end-to-end)
- Loads config from environment (
.env
) and command-line flags. - Lists recent WordPress posts (title, date, link).
- Lets you choose a post interactively.
- Fetches and sanitizes the post’s HTML into readable text.
- Streams TTS audio with OpenAI and writes an MP3.
- Optionally uploads the MP3 to WordPress using Application Passwords.
Prerequisites
- Python 3.10+
- Packages:
requests
,python-dotenv
,openai
- OpenAI API key in your environment (
OPENAI_API_KEY
) - WordPress site with REST API enabled (default for modern WP)
- (Optional) WordPress Application Password for media upload
.env
example:
OPENAI_API_KEY=sk-...
WP_URL=https://your-site.com
WP_USER=your-wp-username
WP_APP_PASSWORD=abcd efgh ijkl mnop
TTS_VOICE=alloy
TTS_MODEL=gpt-4o-mini-tts
TTS_MAX_CHARS=16000
Fetch posts via the WordPress REST API
The script relies on WordPress’s built-in REST endpoints:
def fetch_posts(base_url: str, per_page: int = 20) -> List[Dict]:
url = base_url.rstrip("/") + "/wp-json/wp/v2/posts"
params = {"per_page": per_page, "_fields": "id,slug,title,link,date", "orderby": "date", "order": "desc"}
resp = requests.get(url, params=params, timeout=20)
resp.raise_for_status()
return resp.json()

It then prints a numbered list so you can pick the post:
def choose_post(posts: List[Dict]) -> Optional[Dict]:
for idx, p in enumerate(posts, 1):
title = strip_html(str(p.get("title", {}).get("rendered") or "(untitled)"))
print(f"{idx}. {title}")
# prompt for a number...
Once selected, it fetches the full content:
def fetch_post_content(base_url: str, post_id: int) -> Dict:
url = base_url.rstrip("/") + f"/wp-json/wp/v2/posts/{post_id}"
params = {"_fields": "id,slug,title,content"}
resp = requests.get(url, params=params, timeout=20)
resp.raise_for_status()
return resp.json()
Strip HTML into clean narration text
Blog HTML can be messy for TTS. The strip_html
helper removes scripts/styles and, by default, removes code blocks (they’re often awkward to read aloud). You can include code with --include-code
.
def strip_html(html: str, include_code: bool = False) -> str:
html = re.sub(r"<(script|style)[^>]*>.*?</\1>", " ", html, flags=re.DOTALL|re.IGNORECASE)
if not include_code:
html = re.sub(r"<(pre|code)[^>]*>.*?</\1>", " ", html, flags=re.DOTALL|re.IGNORECASE)
html = re.sub(r"```.*?```", " ", html, flags=re.DOTALL)
html = re.sub(r"<(br|/p|/div|/li|/h[1-6])[^>]*>", "\n", html, flags=re.IGNORECASE)
text = re.sub(r"<[^>]+>", " ", html)
# unescape & normalize
return re.sub(r"\s+", " ", text.replace(" "," ").replace("&","&")).strip()
There’s also a length guard (TTS_MAX_CHARS
) to avoid sending overly long inputs to TTS.
Stream speech to MP3 with OpenAI
The TTS step uses the OpenAI Python SDK’s streaming interface, which writes audio directly to disk (efficient and robust):
def synthesize_to_mp3(text: str, out_path: Path, voice: str = "alloy", model: str = "gpt-4o-mini-tts") -> Path:
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
out_path.parent.mkdir(parents=True, exist_ok=True)
with client.audio.speech.with_streaming_response.create(
model=model,
voice=voice,
input=text,
response_format="mp3",
) as response:
response.stream_to_file(out_path)
return out_path
Why streaming? It avoids buffering the entire audio in memory and handles longer posts gracefully.

Optional: Upload MP3 back to WordPress
If you pass --upload
(or answer “y” at the prompt), the script posts the MP3 to /wp-json/wp/v2/media
using Basic Auth with a WordPress Application Password:
def upload_media(base_url: str, file_path: Path, username: str, app_password: str, *, title=None, description=None) -> Dict:
endpoint = base_url.rstrip("/") + "/wp-json/wp/v2/media"
token = base64.b64encode(f"{username}:{app_password}".encode("utf-8")).decode("ascii")
headers = {"Authorization": f"Basic {token}"}
with open(file_path, "rb") as f:
files = {"file": (file_path.name, f, "audio/mpeg")}
resp = requests.post(endpoint, headers=headers, files=files, data={"title": title or ""}, timeout=120)
resp.raise_for_status()
return resp.json()
On success, WordPress returns the media id
and source_url
so you can embed or share the audio easily.

Command-line usage
The script is fully configurable via flags or environment variables:
python wp_voice_blog.py \
--url https://your-site.com \
--limit 10 \
--voice alloy \
--model gpt-4o-mini-tts \
--outdir WordPress/tts_out \
--upload
Other useful flags
--include-code
— read code blocks aloud.--wp-user
/--wp-app-pass
— override.env
values for uploads.
Security & reliability notes
- Secrets: Keep your
OPENAI_API_KEY
and WP Application Password out of source control. Use.env
locally and secure secrets in CI/CD. - Timeouts: Requests use sensible timeouts; you can tune them if your site is slow.
- Rate limits: If you plan to batch through many posts, add brief sleeps and handle HTTP 429 gracefully.
- Attribution: Consider adding an audio disclaimer (“This post was auto-narrated”) for transparency.
Discover more from CPI Consulting
Subscribe to get the latest posts sent to your email.