On 2026-08-26 I audited 58 articles in this pipeline and found 135 fabricated first-person claims across 50 of them. What the audit found and the Truth Gate that followed is in a separate post. This one is about the second half of that day: programmatically pushing corrected article bodies to Dev.to via the API.
The script I wrote — scripts/devto-sync-corrections.mjs — is 153 lines. Three behaviors made it significantly longer than I expected.
1. There's no URL-to-ID lookup
The Dev.to API doesn't offer an endpoint to fetch an article by URL. The PUT endpoint for updates is /api/articles/{id} where {id} is an integer — not a slug, not a URL fragment.
My frontmatter stores the published URL (published_urls.devto: "https://dev.to/morinaga/...") but not the integer ID. To resolve that mapping, the script has to paginate through GET /api/articles/me/all?per_page=100 until the response returns an empty array:
for (let page = 1; ; page++) {
const batch = await devto(`/articles/me/all?per_page=100&page=${page}`);
if (!batch.length) break;
for (const a of batch) urlToId.set(a.url, a.id);
}
With 258 published articles, that's three API calls before any corrections can begin. Not expensive, but it must run first — and there's no way around it if your local state doesn't store integer IDs.
The lesson: if you're building a pipeline that will ever need to update published articles, store the integer ID in frontmatter at publish time. Retrofitting it costs a full paginated scan every run. The Dev.to API reference documents the /articles/me/all endpoint but doesn't call out this ID resolution requirement explicitly — it's implicit in the fact that the update endpoint is ID-addressed.
2. Root-relative internal links 404 after push
My article bodies contain cross-links in the format /articles/<slug>/ where the slug comes from the local filename. For example, filename 137-2026-08-03-how-i-implemented-quality-contract-v2-four-frontmatter-fields.md produces the local slug how-i-implemented-quality-contract-v2-four-frontmatter-fields.
The problem: Dev.to article URLs don't use that slug format. The actual Dev.to URL for that article is https://dev.to/morinaga/how-i-implemented-qualitycontract-v2-four-fields-that-audit-ai-articles-at-the-source-45jc — a different slug, with a random suffix. A /articles/how-i-implemented-quality-contract-v2-four-frontmatter-fields/ link in the pushed body is a 404 on Dev.to.
The fix: before pushing any body, rewrite all /articles/<slug>/ links to their absolute Dev.to URLs. The script builds a slug → devto URL map from the published_urls.devto frontmatter across all 258 local article files, then runs a regex replace over the body:
const INTERNAL_LINK =
/\[([^\]]*)\]\(\/articles\/([a-z0-9][a-z0-9-]*)\/?(#[^)]*)?\)/g;
body.replace(INTERNAL_LINK, (_m, text, slug, anchor) => {
const url = resolveSlug(slug);
return url ? `[${text}](${url}${anchor ?? ""})` : text;
});
If a slug can't be resolved (the target article has no published Dev.to URL yet), the link is stripped and only the anchor text is kept. Broken link is worse than no link.
This rewriting also handles fragment anchors (#faq) correctly — the replacement includes whatever anchor was on the original link, just attached to the resolved absolute URL.
3. Write rate limiting needs explicit handling
The Dev.to API enforces a write limit of 30 requests per 30 seconds. With 50 articles to correct, sequentially sending all 50 PUTs without throttling will hit the cap mid-run.
Two things to handle:
429 responses with Retry-After. When the cap is hit, the API returns 429 with a Retry-After header indicating how many seconds to wait. The script reads it and sleeps before retrying, up to 3 attempts:
if (res.status === 429 && attempt < 3) {
const wait = Number(res.headers.get("retry-after")) * 1000 || 30_000;
await sleep(wait);
return devto(path, init, attempt + 1);
}
Proactive throttling. Rather than waiting for 429 responses, the script sleeps 1500ms between each PUT to stay well under the limit. 50 corrections × 1.5s ≈ 75 seconds total. Acceptable for a one-time audit run; would need rethinking for a nightly pipeline.
The DRY_RUN=1 environment variable reports what would be changed without sending any PUTs — useful for verifying the URL resolution logic before touching live articles.
Three behaviors in a 153-line script I expected to be 40 lines. The same shape appears in most "sync local state to a third-party API" problems: you need an ID lookup pass, link rewriting, and rate handling even when the happy path looks simple. The API rarely tells you this upfront — you find it when the first real run stalls halfway through.
Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.
Top comments (0)