A week of copy-paste, or a script that reads
The Geared Finance engagement included moving the site off WordPress, and somewhere between sixty and seventy blog posts had to come along. Every one of them was built in Elementor, and Elementor does not write HTML so much as secrete it. A heading arrives wrapped in six divs, each carrying a widget class, a column class and an inline style block restating what the stylesheet already said. An Elementor export is not content; it is a page builder's memory of content. The new Webflow site wanted the opposite: semantic markup, zero inline styles.
The manual route was obvious and terrible. Open a post, paste its HTML into ChatGPT, ask for a cleanup, fix what came back, paste the result into Webflow, repeat. At a realistic pace that is a week of work in which the only human contribution is patience. The same three steps, sixty-odd times, with judgement needed only in the middle one — which is exactly the shape of work worth handing to a machine. So I wrote a Node app instead, and called it what it looked like: a script that does my blogs for me.
Enumerate
Start with every URL the old site admits to
WordPress will not list its posts for you politely, but it will tell a search
engine, and sitemap.xml is the same confession. The first stage read it,
kept anything under /blog/, and that list became the work queue for
everything downstream.
import axios from "axios";
import * as cheerio from "cheerio";
export async function getBlogUrls() {
const { data } = await axios.get(`${process.env.OLD_SITE}/sitemap.xml`);
const $ = cheerio.load(data, { xmlMode: true });
return $("loc")
.map((_, el) => $(el).text().trim())
.get()
.filter((url) => url.includes("/blog/"));
}eve
Each URL was then fetched and reduced to the parts that mattered: the title,
the slug from the path, the cover image src, and the article body — still in
full Elementor dress at this point, divs and all.
Transform
The cleanup is a prompt, not a parser
I tried writing the transform as code first. Strip these classes, unwrap those
divs, delete every style attribute. It kept breaking, because Elementor
markup is not one dialect — it is whatever the page builder felt like on the
day, times sixty posts and several years of theme updates.
A parser has to anticipate every variant. A model just has to read.
So the messy HTML went to Gemini with one job and one hard rule: change the markup, never the words.
import { GoogleGenerativeAI } from "@google/generative-ai";
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);
const model = genAI.getGenerativeModel({ model: "gemini-1.5-flash" });
const CLEANUP_PROMPT = `Below is a blog post exported from WordPress with
Elementor. Remove every wrapper div, class, id and inline style. Return
semantic HTML only — h2, h3, p, ul, ol, li, a, strong, em, blockquote.
Keep every word of the visible text exactly as it is. Do not rewrite,
summarise or add anything. Output raw HTML with no markdown fences.`;
export async function cleanHtml(messy) {
const result = await model.generateContent([CLEANUP_PROMPT, messy]);
return result.response
.text()
.replace(/^```html?\n?/, "")
.replace(/\n?```$/, "")
.trim();
}
The fence-stripping at the end is not decoration. The prompt says no markdown fences, and most of the time the model obeyed; some of the time it wrapped the output in a code fence anyway, and a rule the other side sometimes ignores needs enforcement in code. That small line was my first lesson in the difference between asking a model for a format and guaranteeing one.
Carry the images
Covers travel with their posts
A migrated post without its cover image is half a migration, and the old media library was about to stop existing. So each cover was downloaded, resized and recompressed before it went anywhere near the new site — the originals were whatever the client had uploaded over the years, some of them several megabytes of barely-different pixels.
import sharp from "sharp";
export async function prepareCover(src) {
const { data } = await axios.get(src, { responseType: "arraybuffer" });
return sharp(Buffer.from(data))
.resize({ width: 1600, withoutEnlargement: true })
.webp({ quality: 80 })
.toBuffer();
}
The compressed file went up to Webflow's asset store, and the returned URL became part of the item payload. Nothing in the pipeline ever pointed back at the old domain.
Publish
Push nothing directly
The last stage pushed each cleaned post into the Webflow CMS — always as a
draft. The loop is deliberately boring: one post at a time, a pause between
each, no concurrency. Two rate-limited APIs and sixty items do not need
Promise.all; they need patience, which is the one thing a script has in
unlimited supply.
const WEBFLOW = "https://api.webflow.com/v2";
const headers = { Authorization: `Bearer ${process.env.WEBFLOW_TOKEN}` };
async function createDraft(post) {
const res = await axios.post(
`${WEBFLOW}/collections/${process.env.BLOG_COLLECTION_ID}/items`,
{
isDraft: true,
fieldData: {
name: post.title,
slug: post.slug,
"post-body": post.cleanHtml,
"cover-image": { url: post.coverUrl },
},
},
{ headers }
);
return res.data.id;
}
for (const url of urls) {
const post = await migratePost(url); // fetch → clean → prepare cover
await createDraft(post);
await sleep(1500); // polite to both APIs
}
I ran it, watched the first few posts land, and then it simply carried on without me until all of them were done.
What I refused to trust
The model was the judgement in the middle of the pipeline, and judgement is the part you audit. Three checks stood between Gemini's output and the Webflow write.
The words had to survive. The whole prompt hinged on "keep every word exactly", so after each cleanup I stripped the tags from both versions, collapsed the whitespace, and compared the visible text. A drift of more than a couple of per cent meant the model had started editing rather than cleaning — it happened rarely, but it happened — and that post was logged for a manual pass instead of pushed.
The tags had to be on the list. The cleaned HTML was parsed once more and any element outside the allowed set was grounds for rejection. A model told to return eight tags will occasionally return a ninth, and a rejected item is a retry while a published one is a cleanup.
Everything landed as a draft. The script had the keys to publish and was never given permission to use them; the final skim through the Webflow CMS before hitting publish was fast, human, and non-negotiable. It caught almost nothing, which is the correct amount for a gate to catch. You keep the gate anyway.
In hindsight
Months later, the word for it arrived
At the time I had no vocabulary for any of this. "Agentic AI" was not a phrase I had read; there were no frameworks I knew to reach for, no tool-calling loops worth the name, nobody selling orchestration. I had a deadline, an API key and a folder of HTML that offended me.
Then the term started turning up everywhere, and the definitions kept describing something I recognised: a system that works through a queue of tasks autonomously, uses an LLM for the parts that need reading and judgement, checks its own output, and acts on the world through APIs. Fetch, transform, validate, push, next item — sixty-odd times, unattended. I had built an agent and filed it under chores.
I do not think the missing word cost me anything. If anything the ignorance kept the design honest: with no framework to impress, the script was exactly as complicated as the job, and no more.
The shape outlived the job
What the migration actually taught me was a division of labour. Ordinary code owns the edges — enumerating the work, fetching, writing — because those steps must be exact and repeatable. The model owns the middle, because that is where something has to read messy input and make a call a parser cannot. And between the model and anything irreversible sits a validator that is allowed to say no.
Every agent I have built since is that same shape with better tools. The buzzword arrived late. The pattern was already working.
