After migrating 900+ articles from Zenn to a self-hosted Hugo blog (tech.ldas.jp), the original content remaining on Zenn created a duplicate content issue for SEO. The Zenn-side articles needed to be replaced with redirect notices pointing to the new location.
Editing Zenn Articles Programmatically
Zenn does not appear to offer a public CRUD API for articles when GitHub integration is not used. This led to a Playwright-based browser automation approach.
However, Google login (used for Zenn authentication) blocks automated browsers like Playwright. The workaround was to log in manually in a regular browser and extract _zenn_session and remember_user_token cookies from DevTools > Application > Cookies.
import json
cookies = [
{"name": "_zenn_session", "value": "YOUR_VALUE", "domain": "zenn.dev", "path": "/"},
{"name": "remember_user_token", "value": "YOUR_VALUE", "domain": "zenn.dev", "path": "/"},
]
with open("zenn_cookies.json", "w") as f:
json.dump(cookies, f)
Editor Structure and Internal API
The Zenn article editor URL is https://zenn.dev/articles/{slug}/edit (no username in the path). The editor uses CodeMirror (div.cm-content).
Initially, I attempted to use Playwright keyboard input to modify the editor content, but Zenn’s auto-save did not trigger from synthetic keyboard events. Inspecting the Network tab revealed an internal API: PUT /api/articles/{slug} accepting { "article": { "body_markdown": "..." } } to update article content directly.
Since cookie authentication is bound to the browser context, the API call needs to be made via page.evaluate() inside Playwright.
async def update_article(page, slug, new_body):
result = await page.evaluate("""
async ({slug, body}) => {
const res = await fetch(`/api/articles/${slug}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({article: {body_markdown: body}})
});
return {status: res.status, ok: res.ok};
}
""", {"slug": slug, "body": new_body})
return result
Batch Processing
The Zenn public API GET /api/articles?username=nakamura196&count=100&order=latest returns article listings with pagination. From the full list, 508 articles with zero likes (liked_count == 0) were selected for processing.
Each article body was replaced with:
:::message
この記事は移転しました。
https://tech.ldas.jp/ja/posts/{slug}/
:::
The core batch loop:
import asyncio
from playwright.async_api import async_playwright
async def batch_update(articles):
async with async_playwright() as p:
browser = await p.chromium.launch()
context = await browser.new_context()
with open("zenn_cookies.json") as f:
cookies = json.load(f)
await context.add_cookies(cookies)
page = await context.new_page()
await page.goto("https://zenn.dev")
for i, article in enumerate(articles):
slug = article["slug"]
new_body = f":::message\nこの記事は移転しました。\nhttps://tech.ldas.jp/ja/posts/{slug}/\n:::"
result = await update_article(page, slug, new_body)
print(f"[{i+1}/{len(articles)}] {slug}: {result['status']}")
await asyncio.sleep(0.3)
await browser.close()
All 508 articles were processed at 0.3-second intervals, with every request returning status 200.
Duplicate Content Resolution
An initial approach that kept the original body alongside the redirect notice still resulted in Google flagging duplicate content. Since Zenn does not support custom canonical URLs, removing the original body entirely and leaving only the redirect notice resolved the issue. Google then recognized the tech.ldas.jp version as the original.
Terms of Service Considerations
As of March 2026, Zenn’s terms of service did not appear to explicitly prohibit automation or API usage. However, the terms do prohibit “repeatedly posting text with the same content,” and setting the same redirect notice on 508 articles could arguably fall under a broad interpretation of that clause.
Additionally, /api/articles/{slug} is not an officially documented API – it is an internal endpoint used by the Zenn frontend. It may change or be restricted without notice.
If you consider using a similar approach, review the latest version of the terms of service and proceed at your own discretion. Allow adequate intervals between requests to minimize server load.