|

Automate Image Alt Text at Scale: A Python + Vision-LLM Pipeline for WordPress

Alt text is the one on-page SEO signal that scales badly by hand. A 40-page site is a boring afternoon. A 4,000-URL programmatic site with three images per page is 12,000 alt attributes nobody will ever write. So they stay empty, or worse, they inherit whatever the CMS auto-fills — the filename IMG_4471.jpg, or the same generic string copy-pasted across every product variant. Google’s own image SEO documentation is blunt about it: alt text is the primary way it understands what an image depicts, it feeds Google Images and increasingly the visual context in AI Overviews, and it doubles as your accessibility layer for screen-reader users.

The good news is that alt text is now one of the cleanest automation wins available, because vision-capable LLMs can look at an image and describe it in context. This post walks through a production pipeline that pulls images straight from the WordPress REST API, generates context-aware alt text with a vision model, and writes it back — with guardrails so you never overwrite good human-written descriptions. Every code block below is runnable; adapt the model call to whichever provider you use.

Why generic alt-text scripts fail

The naive version of this — “loop over images, ask a model ‘describe this image’, save the string” — produces alt text that is technically present and practically useless. Two failure modes dominate.

The first is missing page context. A photo of a stainless steel bowl on a white background could be alt-texted as “a metal bowl,” but if it lives on a page titled “5.5 Qt Enameled Dutch Oven — Matte Black,” the useful alt text is “matte black 5.5 quart enameled cast iron Dutch oven, side view.” The model can only produce that if you feed it the page title and surrounding context, not just the pixels.

The second is keyword stuffing. Left unconstrained, models love to write “best professional high-quality Dutch oven for cooking buy online.” That is a spam signal, not a description. Google’s guidance is to describe the image naturally; the fix is an explicit instruction plus a hard character cap. We build both into the prompt.

Step 1 — Pull images that actually need alt text

WordPress exposes every image through /wp-json/wp/v2/media. The alt_text field is what renders as the alt attribute. We only want the ones where it’s empty, so we filter client-side and keep the source URL, the ID, and any post (parent) association so we can grab page context later.

import requests

BASE = "https://example.com/wp-json/wp/v2"
AUTH = ("your_user", "application password here")
UA   = {"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 14.0) "
                       "AppleWebKit/605.1.15 (KHTML, like Gecko) "
                       "Version/17.0 Safari/605.1.15"}

def fetch_media_missing_alt(per_page=100):
    page, missing = 1, []
    while True:
        r = requests.get(f"{BASE}/media", headers=UA, auth=AUTH,
                         params={"per_page": per_page, "page": page,
                                 "media_type": "image",
                                 "_fields": "id,alt_text,source_url,post,title"})
        if r.status_code != 200 or not r.json():
            break
        for m in r.json():
            if not (m.get("alt_text") or "").strip():
                missing.append(m)
        page += 1
    return missing

candidates = fetch_media_missing_alt()
print(f"{len(candidates)} images missing alt text")

That if not alt_text.strip() check is your first guardrail: images a human already described are skipped entirely. You never risk clobbering good copy.

Step 2 — Assemble context per image

For each candidate we want the parent post’s title and, ideally, the H1 or the paragraph nearest the image. The cheap-and-effective version just pulls the parent post title, which alone lifts quality dramatically.

def page_context(post_id):
    if not post_id:
        return ""
    r = requests.get(f"{BASE}/posts/{post_id}", headers=UA, auth=AUTH,
                     params={"_fields": "title,slug"})
    if r.status_code == 200:
        return r.json().get("title", {}).get("rendered", "")
    return ""

Step 3 — Generate alt text with a vision model

Now the core call. The prompt does three jobs: it forces a description grounded in the image, it injects the page context, and it enforces the constraints that keep the output SEO-clean and accessibility-friendly. Point call_vision_model at whichever provider you use — the pattern is identical across Claude, GPT-4o, and Gemini vision endpoints.

PROMPT = """You are writing an HTML alt attribute for an image.

Page context: "{context}"

Rules:
- Describe what is literally visible in the image, in one sentence.
- Use the page context ONLY to disambiguate (e.g. product name, colour), never to add claims you cannot see.
- No keyword stuffing, no marketing adjectives ("best", "premium"), no "image of" / "picture of".
- Maximum 125 characters. Plain descriptive English.

Return ONLY the alt text, nothing else."""

def generate_alt(image_url, context):
    prompt = PROMPT.format(context=context or "none")
    alt = call_vision_model(image_url=image_url, prompt=prompt)  # your provider call
    alt = alt.strip().strip('"')
    return alt[:125]

The 125-character ceiling is deliberate. Screen readers begin to truncate around there, and it forces the model to be specific rather than rambling. The “describe what is literally visible” instruction is your anti-hallucination clause — without it, models invent brand names and materials that aren’t in frame, which is both an SEO liability and an accessibility one.

Step 4 — Write it back, safely

Updating a media item is a POST to /media/{id} with the alt_text field. Two safety layers matter here: run in dry-run mode first and eyeball a sample, and re-check that the field is still empty immediately before writing (in case a human edited it while your job was running).

def write_alt(media_id, alt, dry_run=True):
    if dry_run:
        print(f"[dry-run] {media_id} -> {alt}")
        return
    # re-check freshness to avoid overwriting a human edit
    cur = requests.get(f"{BASE}/media/{media_id}", headers=UA, auth=AUTH,
                       params={"_fields": "alt_text"}).json()
    if (cur.get("alt_text") or "").strip():
        print(f"skip {media_id}: alt now present")
        return
    r = requests.post(f"{BASE}/media/{media_id}", headers=UA, auth=AUTH,
                      data={"alt_text": alt})
    print(f"{'ok' if r.status_code == 200 else 'FAIL'} {media_id}")

for m in candidates:
    ctx = page_context(m.get("post"))
    alt = generate_alt(m["source_url"], ctx)
    write_alt(m["id"], alt, dry_run=True)   # flip to False after review

Note the data= (form-urlencoded) body rather than a JSON payload — many managed WordPress hosts run ModSecurity rules that reject large JSON bodies on write endpoints, and form encoding sails through. If you get a 401 with a scripting library, retry the same request with curl and an explicit browser User-Agent; that combination clears most host-level bot filters.

Results and what to measure

On a mid-sized content site the economics are stark. Twelve thousand images at roughly a third of a cent each in vision-model tokens is around $40 of inference to alt-text an entire library that would take a human weeks. But cost isn’t the KPI — coverage and downstream traffic are.

Track three things after a run. First, alt-text coverage: the percentage of <img> tags with a non-empty alt, which should jump from wherever you started toward 100%. Second, Google Images impressions in Search Console, filtered to the Image search type — this is the lagging indicator that the descriptions are being indexed and surfaced, and it typically moves over four to eight weeks. Third, spot-check accessibility with a screen reader or an automated axe-core scan; good alt text is a WCAG win regardless of the SEO outcome. Teams that pair this with a schema pipeline tend to see compounding gains, because structured data and descriptive alt text reinforce the same entity signals.

Where this fits in a larger automation stack

Alt text is one node in a broader on-page automation graph. The same media-API-plus-LLM pattern powers our schema markup pipeline, and the “generate, then write back through the REST API with guardrails” shape is identical to how we handle automated title and meta description rewrites. If you’re building the internal-linking layer next, the embeddings-based internal linking workflow reuses much of the same image and content extraction code.

Run the whole thing on a schedule — a nightly job that alt-texts any newly uploaded images keeps coverage at 100% forever, instead of letting a backlog rebuild. Self-hosted n8n is a clean way to trigger the script on a cron and pipe failures to Slack.

Found this useful? Bookmark SEO Automation Club and check back each week — we ship a new working automation playbook, code included, every few days. If image and on-page automation is your lane, the schema and internal-linking posts above are the natural next reads.

Frequently asked questions

Will auto-generated alt text hurt my SEO if Google detects it’s AI-written?

No. Google’s stance is that it judges content by quality and helpfulness, not by how it was produced. Descriptive, accurate alt text that helps users and screen readers is exactly what the guidelines ask for. The risk isn’t automation — it’s low-quality output like keyword stuffing or hallucinated descriptions, which the prompt constraints in this pipeline are designed to prevent.

Should I overwrite existing alt text that looks weak?

Be conservative. Human-written alt text, even if terse, reflects intent the model can’t see. This pipeline skips any image that already has a non-empty alt_text. If you do want to improve weak descriptions, run it as a separate, explicitly opt-in pass and review a sample before writing back.

Which vision model should I use?

Any current multimodal model (Claude, GPT-4o, or Gemini vision tiers) handles single-image description well; differences at this task are small. Optimize for cost per image and rate limits rather than benchmark scores, since alt text is a high-volume, low-complexity job. Batch requests where your provider supports it to cut cost further.

How do I handle decorative images that shouldn’t have alt text?

Purely decorative images — spacers, background flourishes — should have an empty alt="" so screen readers skip them, not a description. Add a pre-filter: if an image sits in a known decorative role (by CSS class, dimensions, or folder), exclude it from the candidate list rather than describing it.



Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *