Content & Marketing

Turn a Podcast Episode into a Blog Post

Turn a podcast transcript into a publishable blog draft with Python: strip filler, outline with verified quotes, then draft each section in its own API call.

By the end of this guide you will have a Python script that takes a raw podcast transcript and writes out a markdown blog draft — a title, an intro, four or five sections with headings, and pull-quotes copied word for word from what the guest actually said. Budget about an hour to build it and roughly two minutes per episode to run it. The output is a draft an editor can polish, not a finished article, and that is deliberate: the script does the structural grunt work so your editing time goes into voice and accuracy instead of shaping.

This guide sits inside AI Transcription and Content Repurposing with Python, the series covering what to do with a recording once a machine has turned it into text. If you do not have a transcript yet, Transcribe Audio with the Whisper API and Python produces exactly the plain-text file this script expects.

Prerequisites

You need Python 3.10 or newer, an OpenAI API key, and a transcript saved as transcript.txt in your project folder. If you have not set up an isolated Python workspace yet, Create a Python Virtual Environment for AI walks through it.

python -m venv .venv
source .venv/bin/activate        # Windows: .venv\Scripts\activate
pip install "openai>=1.40" "python-dotenv>=1.0"

Put your key in a .env file — a plain text file of NAME=value lines that your script reads at startup, so the key never appears in your code:

OPENAI_API_KEY=sk-your-key-here

Then add that file to .gitignore before you commit anything, so the key cannot travel to a public repository:

echo ".env" >> .gitignore

The transcript itself can be one paragraph per speaker turn or one line per sentence. Speaker labels like SPEAKER 1: or Alex: are helpful but not required. If your transcript runs past roughly 40,000 words you will need to work on it in pieces; Split Long Text into Chunks for AI APIs covers the mechanics.

Step 1 — Clean the transcript before the model reads it

Speech is not writing. A transcript is full of things nobody wants in an article: filler sounds, restarts where the speaker changed their mind mid-sentence, doubled words, machine timestamps, and speaker tags that read like a court record. Every one of those is a token you pay for and a distraction that makes the model's job harder. Worse, they poison quotes — a quote that includes "um, I mean" is unusable even if it is technically accurate.

Do this pass in plain Python with regular expressions, not with an API call. Regular expressions are patterns for finding text; they are fast, free, and, most importantly, they never invent anything. A model asked to "tidy this transcript" will quietly rewrite sentences, and once that happens your quotes no longer match the source.

Three transcript defects and what the cleaning pass turns them into A before and after table with three rows: filler words become dropped text, false starts are collapsed to a single phrase, and machine speaker labels are normalised to readable names. Raw transcript After the regex Filler words um, uh, you know Dropped no model involved False starts I think - I think the Collapsed I think the Speaker tags SPEAKER 2: [00:14:07] Normalised Guest:
Every transformation on this page happens in Python, not in a prompt, so the cleaned text stays a faithful record of what was said.

Save this as clean.py:

import re
from pathlib import Path

FILLER = re.compile(r"(?:,\s*)?\b(?:um|uh|erm|you know|i mean|sort of|kind of)\b,?\s*", re.IGNORECASE)
TIMECODE = re.compile(r"\[?\b\d{1,2}:\d{2}(?::\d{2})?\b\]?\s*")
FALSE_START = re.compile(r"\b([\w']+(?:\s+[\w']+){0,3})\s*[-]+\s*(?=\1\b)", re.IGNORECASE)
STUTTER = re.compile(r"\b([\w']+)(?:\s+\1\b)+", re.IGNORECASE)
LOOSE_PUNCT = re.compile(r"\s+([,.;:!?])")
DOUBLE_PUNCT = re.compile(r"[,;:](\s*[,.;:!?])")
SPACES = re.compile(r"\s{2,}")
SPEAKER_LINE = re.compile(r"^\s*([A-Za-z][A-Za-z ]*\d*)\s*:\s*(.*)$")

NAMES = {"SPEAKER 1": "Host", "SPEAKER 2": "Guest"}


def clean(text: str) -> str:
    """Strip speech artefacts from one turn without rewording it."""
    text = TIMECODE.sub("", text)
    text = FILLER.sub(" ", text)
    text = FALSE_START.sub("", text)
    text = STUTTER.sub(r"\1", text)
    text = LOOSE_PUNCT.sub(r"\1", text)
    text = DOUBLE_PUNCT.sub(r"\1", text)
    text = SPACES.sub(" ", text).strip()
    return text[:1].upper() + text[1:] if text else text


def clean_transcript(raw: str) -> str:
    lines = []
    for line in raw.splitlines():
        line = line.strip()
        if not line:
            continue
        match = SPEAKER_LINE.match(line)
        if match:
            speaker = NAMES.get(match.group(1).upper().strip(), match.group(1).strip())
            body = clean(match.group(2))
        else:
            speaker, body = "", clean(line)
        if body:
            lines.append(f"{speaker}: {body}" if speaker else body)
    return "\n".join(lines)


if __name__ == "__main__":
    raw = Path("transcript.txt").read_text(encoding="utf-8")
    cleaned = clean_transcript(raw)
    Path("cleaned.txt").write_text(cleaned, encoding="utf-8")
    print(f"{len(raw.split())} words in, {len(cleaned.split())} words out")

Two of those patterns exist only to tidy up after the others. Deleting "you know" from "the big mistake, you know, is timing" would otherwise leave a stranded comma, so FILLER swallows a comma on either side and LOOSE_PUNCT pulls any remaining punctuation back against the word before it. DOUBLE_PUNCT removes the duplicate that appears when a filler phrase sat at the end of a sentence. None of these change a word; they only repair spacing and punctuation that the deletions disturbed.

Run python clean.py and read cleaned.txt yourself before going further. A ten to fifteen percent word drop is normal for conversational speech. Notice that "like" is deliberately absent from the FILLER list: removing it breaks sentences such as "it works like a queue". Cleaners that are too aggressive damage meaning, and from here on cleaned.txt is the only version of the truth your quote checker will compare against.

Step 2 — Outline first, with the quotes attached

This is the step that separates a usable draft from mush. Before any prose exists, make one API call whose entire job is to decide the shape of the article: a working title, an angle, and a list of sections. Crucially, each section also carries the exact transcript sentences that support it. Structure is decided once, in one place, by a model that can see the whole conversation at once.

Ask for JSON — a structured data format the model can emit and Python can load into a dictionary — rather than prose. Set response_format={"type": "json_object"} so the API guarantees valid JSON, and keep temperature low, because you want a consistent structural judgement rather than a creative one. If temperature is new to you, What Is Temperature in an LLM API? explains the dial in one page.

Add this to a new file, outline.py:

import json
import os
from pathlib import Path
from dotenv import load_dotenv
from openai import OpenAI

load_dotenv()  # reads .env, which is listed in .gitignore
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))

OUTLINE_SYSTEM = """You are a structural editor turning a podcast transcript into a blog post.
Return JSON only, matching this shape exactly:
{"title": str, "angle": str, "sections": [{"heading": str, "point": str, "quotes": [str]}]}
Rules:
- 4 to 6 sections, ordered as an argument, not as a replay of the conversation.
- "point" is one sentence naming what that section must prove.
- "quotes" holds 1 or 2 sentences COPIED CHARACTER FOR CHARACTER from the transcript.
- Never paraphrase, tidy or shorten a quote. If no sentence fits, return an empty list."""


def build_outline(transcript: str) -> dict:
    response = client.chat.completions.create(
        model="gpt-4o",
        temperature=0.2,
        response_format={"type": "json_object"},
        messages=[
            {"role": "system", "content": OUTLINE_SYSTEM},
            {"role": "user", "content": f"Transcript:\n\n{transcript}"},
        ],
    )
    return json.loads(response.choices[0].message.content)


def verify_quotes(outline: dict, transcript: str) -> dict:
    """Delete any quote that is not literally present in the transcript."""
    haystack = " ".join(transcript.split()).lower()
    for section in outline.get("sections", []):
        kept = []
        for quote in section.get("quotes", []):
            needle = " ".join(quote.split()).lower()
            if needle in haystack:
                kept.append(quote)
            else:
                print(f"  dropped invented quote: {quote[:70]}")
        section["quotes"] = kept
    return outline


if __name__ == "__main__":
    transcript = Path("cleaned.txt").read_text(encoding="utf-8")
    outline = verify_quotes(build_outline(transcript), transcript)
    Path("outline.json").write_text(json.dumps(outline, indent=2), encoding="utf-8")
    for section in outline["sections"]:
        print(f"{section['heading']}{len(section['quotes'])} verified quote(s)")

verify_quotes is the most important function in this guide. Models are strong at finding the right moment in a conversation and weak at reproducing it letter-perfect; they will helpfully fix grammar, merge two sentences, or smooth an awkward phrase. Searching for the normalised quote inside the normalised transcript catches every one of those edits. Collapsing whitespace on both sides means a line break in the transcript will not cause a false rejection, while a changed word still will.

Resist the temptation to soften that check into a fuzzy match that accepts anything "close enough". A quote is either what the guest said or it is not, and a similarity score of ninety percent is exactly how a negation gets dropped or a hedge gets removed. Keep the comparison exact and accept that you will lose a few candidates; the ones that survive are the ones you can defend if the guest reads the published article and disagrees with it.

Open outline.json and read it before you draft. If the headings look wrong, fix the system prompt or rerun — regenerating an outline costs one call, whereas rewriting five drafted sections costs five. For more control over what a model returns, Write System Prompts that Control Output Format goes deeper on this pattern.

Step 3 — Draft one section per call

Now the outline is fixed, generate prose one section at a time. Each call sees the article title, its own heading, the single point that section must make, and only its own quotes. It never sees the other sections, so it cannot drift into them or repeat their material.

Two things improve immediately. Length becomes predictable, because a word target applied to 200 words is followed far more reliably than a word target applied to 1,200. And cost becomes visible per section, so a runaway request is obvious in the logs rather than buried in one enormous response. This is prompt chaining — feeding the output of one call into the next — and Chain Prompts Together in Python covers the general pattern.

One outline call fans out into a separate drafting call per section A data flow diagram: a single outline call on the left branches into three independent section drafting calls, whose outputs converge into one assembled markdown draft on the right. One outline call JSON plan + quotes Section 1 draft own call, own quote Section 2 draft own call, own quote Section 3 draft own call, own quote Assembled draft title, intro, quotes
Because the outline is decided first, each drafting call is small, independent and rerunnable — a weak section can be regenerated without touching the rest of the article.

Add these functions to outline.py, or import them from it, so the client and the outline loader are shared:

SECTION_SYSTEM = """You write one section of a blog post adapted from a podcast.
Write plain body prose in the third person, referring to the speaker as "the guest".
Do not write the heading. Do not write an introduction or a conclusion.
Do not add facts, numbers or claims that are absent from the supplied material.
You may reference a supplied quote, but never alter its wording."""


def draft_section(section: dict, title: str, angle: str, words: int = 200) -> str:
    quotes = "\n".join(f"- {q}" for q in section["quotes"]) or "- (no quote available)"
    user = (
        f"Article title: {title}\n"
        f"Angle: {angle}\n"
        f"Section heading: {section['heading']}\n"
        f"Point this section must make: {section['point']}\n"
        f"Supporting quotes:\n{quotes}\n\n"
        f"Write about {words} words of body prose for this section only."
    )
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        temperature=0.4,
        max_tokens=600,
        messages=[
            {"role": "system", "content": SECTION_SYSTEM},
            {"role": "user", "content": user},
        ],
    )
    return response.choices[0].message.content.strip()

Note the model swap. The outline call used gpt-4o because choosing an argument and locating supporting evidence is genuinely hard; the drafting calls use gpt-4o-mini because every decision has already been made and the remaining work is writing tidy sentences. On a five-section article that is one expensive call and five cheap ones. To see what that actually costs you, Count Tokens in Python Before You Send shows how to measure a request before it leaves your machine.

Step 4 — Assemble the markdown and place the quotes verbatim

Assembly is pure Python string work, and it must stay that way. The drafting model has already written the prose; if you now hand the whole article back to a model and ask it to "polish and add the quotes", it will rewrite them. Instead, copy each verified quote out of outline.json — the same string your checker approved — and paste it into a markdown blockquote around the drafted prose.

The rule to internalise: a quote is data, not output. It travels from transcript to JSON to markdown by copying, and it is never regenerated at any stage. Anything the model produces goes into body paragraphs where it reads as your summary, not as the guest's words.

Decision rule for whether a sentence may be published as a quote A decision tree: if a candidate sentence is found word for word in the cleaned transcript it becomes an attributed blockquote, otherwise it stays as ordinary paraphrased body prose. Word for word in the clean transcript? yes no Keep as a quote copy the exact string Demote to prose paraphrase, no marks Blockquote in md attributed to guest Plain paragraph no quotation marks
Only a string that survives an exact search against the cleaned transcript is allowed to appear between quotation marks; everything else is demoted to ordinary prose.

Save this as build_post.py. It ties the three steps together and writes the finished file:

import json
import os
from pathlib import Path
from dotenv import load_dotenv
from openai import OpenAI

from outline import build_outline, verify_quotes, draft_section

load_dotenv()
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))


def draft_intro(outline: dict) -> str:
    headings = ", ".join(s["heading"] for s in outline["sections"])
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        temperature=0.4,
        max_tokens=250,
        messages=[
            {"role": "system", "content": "You write blog intros. Two short paragraphs, no heading."},
            {"role": "user", "content": (
                f"Title: {outline['title']}\nAngle: {outline['angle']}\n"
                f"Sections to come: {headings}\n"
                "Set up the argument. Do not quote anyone."
            )},
        ],
    )
    return response.choices[0].message.content.strip()


def assemble(outline: dict, intro: str, bodies: list[str]) -> str:
    parts = [f"# {outline['title']}", "", intro, ""]
    for section, body in zip(outline["sections"], bodies):
        parts += [f"## {section['heading']}", "", body, ""]
        if section["quotes"]:
            parts += [f"> {section['quotes'][0]}", "", "*— the guest*", ""]
    return "\n".join(parts)


if __name__ == "__main__":
    transcript = Path("cleaned.txt").read_text(encoding="utf-8")
    outline = verify_quotes(build_outline(transcript), transcript)
    Path("outline.json").write_text(json.dumps(outline, indent=2), encoding="utf-8")

    bodies = []
    for index, section in enumerate(outline["sections"], start=1):
        print(f"drafting {index}/{len(outline['sections'])}: {section['heading']}")
        bodies.append(draft_section(section, outline["title"], outline["angle"]))

    post = assemble(outline, draft_intro(outline), bodies)
    Path("post.md").write_text(post, encoding="utf-8")
    print(f"wrote post.md — {len(post.split())} words")

Run python build_post.py and open post.md. You should see a titled article with an intro, one heading per outlined section, and a blockquote under any section that had a verified quote. Sections whose quotes were all rejected simply appear without one, which is the correct failure mode: fewer quotes, never invented ones. From here the draft is yours to edit — cut a weak section, reorder two, and rerun draft_section on anything that reads thin.

Three things are worth checking by hand on every run, because no script can judge them. Read each blockquote in context and confirm it still means what it meant in the conversation — a sentence can be word-perfect and still mislead once the question that prompted it is gone. Check that the drafted prose around a quote does not restate it, which happens when the model treats the quote as a summary target. And confirm the intro promises only what the sections deliver, since the intro was written from headings rather than from the finished text.

Quick reference: the four calls

CallModelTemperatureReturns
Outlinegpt-4o0.2JSON: title, angle, sections, quotes
Section draftgpt-4o-mini0.4~200 words of body prose
Introgpt-4o-mini0.4Two short opening paragraphs
Cleaningnonen/aRegex only, runs locally and free

Troubleshooting

  • json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0) — the reply was wrapped in a markdown code fence instead of being raw JSON. Cause: response_format was omitted or the model was told to "show" the JSON. Fix: keep response_format={"type": "json_object"} on the outline call, and see Fix JSONDecodeError with AI API Responses in Python for a defensive parser.
  • openai.BadRequestError: ... maximum context length ... tokens — the transcript is longer than the model's input window. Cause: an episode over roughly an hour with no chunking. Fix: outline the episode in halves and merge the two section lists, following Fix the Context-Length-Exceeded Error in Python.
  • KeyError: 'quotes' — a section object came back without the key. Cause: the model dropped a field it had nothing to put in. Fix: read every field with section.get("quotes", []) rather than square brackets, as verify_quotes already does.
  • Every quote prints dropped invented quote — the checker is comparing against different text from the one the model saw. Cause: clean.py was rerun with new rules after outline.json was produced. Fix: always pass the same cleaned.txt string to build_outline and verify_quotes in a single run, which the script above does by design.

When to use this vs. alternatives

  • Use this workflow when the episode contains a real argument and you want the guest's own phrasing to carry the article. The outline-then-draft split costs a handful of calls and gives you structure you can inspect before any prose is written.
  • Use a straight summary instead when you only need the gist — a recap paragraph for a newsletter or show notes. Summarize a YouTube Video with Python does that in one call, with none of the quote machinery here.
  • Start from a brief rather than a transcript when there is no interview at all. If the source is your own outline or a keyword list, Generate Blog Posts with the OpenAI API is the shorter path, and Generate Meta Descriptions in Bulk with Python finishes either route off.

The habit worth keeping from this page is the separation of jobs: regex for anything that must not change, one structured call for judgement, small independent calls for prose, and plain Python for assembly. Once one episode works, the same script handles a back catalogue in a loop, and the same cleaned transcript feeds other outputs — Generate SRT Subtitles with Python and AI reuses it for video captions without a second transcription bill.

Back to AI Transcription and Content Repurposing with Python.

Frequently asked questions

Can I just paste a whole podcast transcript into ChatGPT and ask for a blog post?

You can, and you will usually get a short, vague article that flattens the best moments of the conversation. A single call has to invent structure and prose at the same time, so it compresses. Splitting the job into an outline call and one call per section keeps length and detail under your control.

How do I stop the model from making up quotes?

Never ask it to produce quotes as prose. Ask for exact substrings of the transcript in a JSON field, then verify each one in Python by searching the transcript for it. Drop anything that does not match. That check takes four lines of code and is the difference between quoting a guest and libelling one.

How long should each drafted section be?

Between 150 and 250 words works well for a web article. Give the model a word target in the user message and a max_tokens ceiling in the API call. Because each section is a separate request, one long section cannot eat the budget that belonged to the others.

Which model should I use for this workflow?

Use a stronger model such as gpt-4o for the outline, because structure and quote selection are the hard parts. Use a cheaper model such as gpt-4o-mini for the individual section drafts, since the outline has already made every judgement call and the model only has to write clean prose.

Do I need the audio file, or is a transcript enough?

A transcript is enough for everything on this page. If you only have audio, transcribe it first with the Whisper API, which returns plain text you can feed straight into the cleaning step. Timestamps are optional here but useful if you also want subtitles from the same recording.